From 2afc9d737dca063f6a00e298e27f841a3ad092df Mon Sep 17 00:00:00 2001 From: Akylbek Utekeshev Date: Mon, 6 Apr 2026 19:55:40 +0500 Subject: [PATCH 01/24] MOBILE-72: Refactor HTTP response handling to prioritize HTTP status code (#692) * MOBILE-72: Refactor HTTP response handling to prioritize HTTP status code HTTP code is now the primary discriminator. Body status is only validated within the context of the HTTP code category. Fixes: 4xx + status "Success" no longer treated as success. * MOBILE-72: Add tests for HTTP response handling 12 test cases covering all key scenarios from the API spec: 2xx/4xx/5xx with various body statuses, needBaseResponse=false, emptyData=true, 404 special case, 3xx redirect handling. * MOBILE-72: Add comprehensive tests for HTTP response handling and default parameter for needBaseResponse --------- Co-authored-by: Vailence --- Mindbox.xcodeproj/project.pbxproj | 13 + Mindbox/Network/Abstract/NetworkFetcher.swift | 12 +- Mindbox/Network/MBNetworkFetcher.swift | 135 ++-- .../Event/MBEventRepository.swift | 2 +- ...BNetworkFetcherResponseHandlingTests.swift | 696 ++++++++++++++++++ 5 files changed, 810 insertions(+), 48 deletions(-) create mode 100644 MindboxTests/Network/MBNetworkFetcherResponseHandlingTests.swift diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index b1cad161f..57265de33 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 0A3D045A2BC6803E00E1FC52 /* ImageFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A3D04592BC6803E00E1FC52 /* ImageFormat.swift */; }; 0E7A224A082FA2DA35706CC7 /* MotionServiceResolvePositionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8192B8B7043EF74D05B36B /* MotionServiceResolvePositionTests.swift */; }; 0E7A224A082FA2DA35706CC8 /* MotionServiceShakeToEditTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8192B8B7043EF74D05B36C /* MotionServiceShakeToEditTests.swift */; }; + 1E3BD63AB3F1521C253CB818 /* MBNetworkFetcherResponseHandlingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97FEDDEB5F71A67F1C4C675F /* MBNetworkFetcherResponseHandlingTests.swift */; }; 302E35788CBDA959283569F4 /* MotionServiceBehaviorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0DB93A7997961CA7C2BE917 /* MotionServiceBehaviorTests.swift */; }; 313B233A25ADEA0F00A1CB72 /* Mindbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 313B233025ADEA0F00A1CB72 /* Mindbox.framework */; }; 313B233F25ADEA0F00A1CB72 /* MindboxTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 313B233E25ADEA0F00A1CB72 /* MindboxTests.swift */; }; @@ -1054,6 +1055,7 @@ 84FCD3B825CA109E00D1E574 /* MockNetworkFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNetworkFetcher.swift; sourceTree = ""; }; 84FCD3BC25CA10F600D1E574 /* SuccessResponse.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = SuccessResponse.json; sourceTree = ""; }; 9778038796A8426ABDED1E97 /* FeatureTogglesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureTogglesModel.swift; sourceTree = ""; }; + 97FEDDEB5F71A67F1C4C675F /* MBNetworkFetcherResponseHandlingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MBNetworkFetcherResponseHandlingTests.swift; sourceTree = ""; }; 9B24FAAB28C74B8300F10B5D /* InAppConfigurationRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppConfigurationRepository.swift; sourceTree = ""; }; 9B24FAAD28C74BA500F10B5D /* InAppCoreManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppCoreManager.swift; sourceTree = ""; }; 9B24FAB028C74BD200F10B5D /* InAppConfigurationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppConfigurationManager.swift; sourceTree = ""; }; @@ -1613,6 +1615,7 @@ 84DC49D525D185A600D5D758 /* Supporting Files */, 313B234025ADEA0F00A1CB72 /* Info.plist */, 73662EFB100A1A3520058D4E /* TrackVisitManager */, + 64FD3F7576619DCF106D10B6 /* Network */, ); path = MindboxTests; sourceTree = ""; @@ -2249,6 +2252,15 @@ name = Frameworks; sourceTree = ""; }; + 64FD3F7576619DCF106D10B6 /* Network */ = { + isa = PBXGroup; + children = ( + 97FEDDEB5F71A67F1C4C675F /* MBNetworkFetcherResponseHandlingTests.swift */, + ); + name = Network; + path = Network; + sourceTree = ""; + }; 73662EFB100A1A3520058D4E /* TrackVisitManager */ = { isa = PBXGroup; children = ( @@ -4709,6 +4721,7 @@ 0E7A224A082FA2DA35706CC7 /* MotionServiceResolvePositionTests.swift in Sources */, 0E7A224A082FA2DA35706CC8 /* MotionServiceShakeToEditTests.swift in Sources */, 302E35788CBDA959283569F4 /* MotionServiceBehaviorTests.swift in Sources */, + 1E3BD63AB3F1521C253CB818 /* MBNetworkFetcherResponseHandlingTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Mindbox/Network/Abstract/NetworkFetcher.swift b/Mindbox/Network/Abstract/NetworkFetcher.swift index 1f1dfc191..899ddc368 100644 --- a/Mindbox/Network/Abstract/NetworkFetcher.swift +++ b/Mindbox/Network/Abstract/NetworkFetcher.swift @@ -21,7 +21,17 @@ protocol NetworkFetcher { route: Route, completion: @escaping ((Result) -> Void) ) - + /// Cancels all ongoing network tasks. func cancelAllTasks() } + +extension NetworkFetcher { + func request( + type: T.Type, + route: Route, + completion: @escaping ((Result) -> Void) + ) where T: Decodable { + request(type: type, route: route, needBaseResponse: true, completion: completion) + } +} diff --git a/Mindbox/Network/MBNetworkFetcher.swift b/Mindbox/Network/MBNetworkFetcher.swift index bd8a81c6c..9da733fda 100644 --- a/Mindbox/Network/MBNetworkFetcher.swift +++ b/Mindbox/Network/MBNetworkFetcher.swift @@ -211,88 +211,131 @@ class MBNetworkFetcher: NetworkFetcher { needBaseResponse: Bool, completion: @escaping ((Result) -> Void) ) { - if context.statusCode == .serverError { - let body = String(data: data, encoding: .utf8) - completion(.failure(internalServerError(httpStatusCode: context.httpResponse.statusCode, networkTimeMs: context.networkTimeMs, responseBody: body))) - return - } - - do { - try decodeResponseData( + switch context.statusCode { + case .success: + handleSuccessResponseData( data, context: context, + emptyData: emptyData, needBaseResponse: needBaseResponse, completion: completion ) - } catch let decodingError { - handleDecodingError( - decodingError, - data: data, + case .clientError: + handleClientErrorResponseData( + data, + context: context, + completion: completion + ) + case .serverError: + handleServerErrorResponseData( + data, context: context, - emptyData: emptyData, completion: completion ) + case .redirection: + completion(.failure(.invalidResponse(context.response))) } } - private func decodeResponseData( + // MARK: - 2xx Success + + private func handleSuccessResponseData( _ data: Data, context: ResponseContext, + emptyData: Bool, needBaseResponse: Bool, completion: @escaping ((Result) -> Void) - ) throws { + ) { if !needBaseResponse { completion(.success(data)) return } let decoder = JSONDecoder() - // Decoding to structure with `status` field - let base = try decoder.decode(BaseResponse.self, from: data) - // Figure out what server returned - switch base.status { - case .success, .transactionAlreadyProcessed: - completion(.success(data)) - case .validationError: - let error = try decoder.decode(ValidationError.self, from: data) - completion(.failure(.validationError(error))) - case .protocolError: - let error = try decoder.decode(ProtocolError.self, from: data) - completion(.failure(.protocolError(error))) - case .internalServerError: - let error = try decoder.decode(ProtocolError.self, from: data) - completion(.failure(.serverError(error))) - case .unknown: - completion(.failure(.invalidResponse(context.response))) + do { + let base = try decoder.decode(BaseResponse.self, from: data) + switch base.status { + case .success, .transactionAlreadyProcessed: + completion(.success(data)) + case .validationError: + let error = try decoder.decode(ValidationError.self, from: data) + completion(.failure(.validationError(error))) + case .protocolError, .internalServerError, .unknown: + completion(.failure(.invalidResponse(context.response))) + } + } catch { + if emptyData { + completion(.success(data)) + } else { + completion(.failure(.internalError(.init(errorKey: .parsing, rawError: error)))) + } } } - private func handleDecodingError( - _ decodingError: Error, - data: Data, + // MARK: - 4xx Client Error + + private func handleClientErrorResponseData( + _ data: Data, context: ResponseContext, - emptyData: Bool, completion: @escaping ((Result) -> Void) ) { - switch context.statusCode { - case .serverError: - let body = String(data: data, encoding: .utf8) - completion(.failure(internalServerError(httpStatusCode: context.httpResponse.statusCode, networkTimeMs: context.networkTimeMs, responseBody: body))) - default: - if emptyData { - completion(.success(data)) - } else if context.httpResponse.statusCode == 404 { + let httpCode = context.httpResponse.statusCode + let decoder = JSONDecoder() + + do { + let base = try decoder.decode(BaseResponse.self, from: data) + switch base.status { + case .protocolError: + let error = try decoder.decode(ProtocolError.self, from: data) + completion(.failure(.protocolError(error))) + case .validationError: + let error = try decoder.decode(ValidationError.self, from: data) + completion(.failure(.validationError(error))) + default: + completion(.failure(.protocolError(.init( + status: .protocolError, + errorMessage: "Client error", + httpStatusCode: httpCode + )))) + } + } catch { + if httpCode == 404 { completion(.failure(.protocolError(.init( status: .protocolError, errorMessage: "Invalid request url", - httpStatusCode: context.httpResponse.statusCode + httpStatusCode: httpCode )))) } else { - completion(.failure(.internalError(.init(errorKey: .parsing, rawError: decodingError)))) + completion(.failure(.protocolError(.init( + status: .protocolError, + errorMessage: "Client error", + httpStatusCode: httpCode + )))) } } } + // MARK: - 5xx Server Error + + private func handleServerErrorResponseData( + _ data: Data, + context: ResponseContext, + completion: @escaping ((Result) -> Void) + ) { + let decoder = JSONDecoder() + do { + let error = try decoder.decode(ProtocolError.self, from: data) + completion(.failure(.serverError(error))) + } catch { + let body = String(data: data, encoding: .utf8) + completion(.failure(internalServerError( + httpStatusCode: context.httpResponse.statusCode, + networkTimeMs: context.networkTimeMs, + responseBody: body + ))) + } + } + private func handleMissingData( error: Error?, context: ResponseContext, diff --git a/Mindbox/NetworkRepository/Event/MBEventRepository.swift b/Mindbox/NetworkRepository/Event/MBEventRepository.swift index 91185f083..3beaee1a2 100644 --- a/Mindbox/NetworkRepository/Event/MBEventRepository.swift +++ b/Mindbox/NetworkRepository/Event/MBEventRepository.swift @@ -68,7 +68,7 @@ class MBEventRepository: EventRepository { deviceUUID: deviceUUID ) let route = makeRoute(wrapper: wrapper) - fetcher.request(type: type, route: route, needBaseResponse: true, completion: { result in + fetcher.request(type: type, route: route, completion: { result in DispatchQueue.main.async { switch result { case let .failure(error): diff --git a/MindboxTests/Network/MBNetworkFetcherResponseHandlingTests.swift b/MindboxTests/Network/MBNetworkFetcherResponseHandlingTests.swift new file mode 100644 index 000000000..d12e39da3 --- /dev/null +++ b/MindboxTests/Network/MBNetworkFetcherResponseHandlingTests.swift @@ -0,0 +1,696 @@ +// +// MBNetworkFetcherResponseHandlingTests.swift +// MindboxTests +// +// Created on 2026-04-02. +// + +import XCTest +@testable import Mindbox + +final class MBNetworkFetcherResponseHandlingTests: XCTestCase { + + // MARK: - Setup + + private func makeFetcher() throws -> MBNetworkFetcher { + let persistenceStorage = MockPersistenceStorage() + persistenceStorage.configuration = try MBConfiguration( + endpoint: "test-endpoint", + domain: "api.mindbox.ru" + ) + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [StubURLProtocol.self] + let session = URLSession(configuration: configuration) + return MBNetworkFetcher(persistenceStorage: persistenceStorage, session: session) + } + + private func stubResponse(statusCode: Int, body: Data? = nil) { + StubURLProtocol.requestHandler = { request in + let response = try XCTUnwrap( + HTTPURLResponse( + url: try XCTUnwrap(request.url), + statusCode: statusCode, + httpVersion: nil, + headerFields: nil + ) + ) + return (response, body) + } + } + + private func baseResponseData(status: String) -> Data { + // swiftlint:disable:next force_try + try! JSONSerialization.data(withJSONObject: ["status": status]) + } + + private func protocolErrorData(status: String, message: String, httpCode: Int) -> Data { + // swiftlint:disable:next force_try + try! JSONSerialization.data(withJSONObject: [ + "status": status, + "errorMessage": message, + "httpStatusCode": httpCode + ]) + } + + private func validationErrorData() -> Data { + // swiftlint:disable:next force_try + try! JSONSerialization.data(withJSONObject: [ + "status": "ValidationError", + "validationMessages": [ + ["message": "Invalid email", "location": "/customer/email"] + ] + ]) + } + + // MARK: - 2xx + Success status → success + + func test_http200_statusSuccess_returnsSuccess() throws { + let fetcher = try makeFetcher() + let body = baseResponseData(status: "Success") + stubResponse(statusCode: 200, body: body) + + let expectation = expectation(description: "completion") + fetcher.request(route: FetchInAppGeoRoute()) { result in + switch result { + case .success: + break // expected + case .failure(let error): + XCTFail("Expected success, got \(error)") + } + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - 2xx + TransactionAlreadyProcessed → success + + func test_http200_statusTransactionAlreadyProcessed_returnsSuccess() throws { + let fetcher = try makeFetcher() + let body = baseResponseData(status: "TransactionAlreadyProcessed") + stubResponse(statusCode: 200, body: body) + + let expectation = expectation(description: "completion") + fetcher.request(route: FetchInAppGeoRoute()) { result in + switch result { + case .success: + break // expected + case .failure(let error): + XCTFail("Expected success, got \(error)") + } + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - 2xx + ValidationError → .validationError + + func test_http200_statusValidationError_returnsValidationError() throws { + let fetcher = try makeFetcher() + let body = validationErrorData() + stubResponse(statusCode: 200, body: body) + + let expectation = expectation(description: "completion") + fetcher.request(route: FetchInAppGeoRoute()) { result in + switch result { + case .success: + XCTFail("Expected validationError") + case .failure(let error): + guard case .validationError = error else { + XCTFail("Expected validationError, got \(error)") + expectation.fulfill() + return + } + } + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - 4xx + status "Success" in body → protocolError (NOT success) + + func test_http400_statusSuccess_returnsProtocolError() throws { + let fetcher = try makeFetcher() + let body = baseResponseData(status: "Success") + stubResponse(statusCode: 400, body: body) + + let expectation = expectation(description: "completion") + fetcher.request(route: FetchInAppGeoRoute()) { result in + switch result { + case .success: + XCTFail("HTTP 400 with Success status should NOT be treated as success") + case .failure(let error): + guard case .protocolError = error else { + XCTFail("Expected protocolError, got \(error)") + expectation.fulfill() + return + } + } + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - 400 + ProtocolError → .protocolError + + func test_http400_statusProtocolError_returnsProtocolError() throws { + let fetcher = try makeFetcher() + let body = protocolErrorData(status: "ProtocolError", message: "Bad request", httpCode: 400) + stubResponse(statusCode: 400, body: body) + + let expectation = expectation(description: "completion") + fetcher.request(route: FetchInAppGeoRoute()) { result in + switch result { + case .success: + XCTFail("Expected protocolError") + case .failure(let error): + guard case .protocolError(let pe) = error else { + XCTFail("Expected protocolError, got \(error)") + expectation.fulfill() + return + } + XCTAssertEqual(pe.httpStatusCode, 400) + XCTAssertEqual(pe.errorMessage, "Bad request") + } + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - 404 with unparseable body → protocolError "Invalid request url" + + func test_http404_unparseableBody_returnsProtocolErrorInvalidUrl() throws { + let fetcher = try makeFetcher() + let body = "not json".data(using: .utf8) + stubResponse(statusCode: 404, body: body) + + let expectation = expectation(description: "completion") + fetcher.request(route: FetchInAppGeoRoute()) { result in + switch result { + case .success: + XCTFail("Expected protocolError for 404") + case .failure(let error): + guard case .protocolError(let pe) = error else { + XCTFail("Expected protocolError, got \(error)") + expectation.fulfill() + return + } + XCTAssertEqual(pe.httpStatusCode, 404) + XCTAssertEqual(pe.errorMessage, "Invalid request url") + } + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - 429 + ProtocolError → .protocolError + + func test_http429_statusProtocolError_returnsProtocolError() throws { + let fetcher = try makeFetcher() + let body = protocolErrorData(status: "ProtocolError", message: "Rate limit exceeded", httpCode: 429) + stubResponse(statusCode: 429, body: body) + + let expectation = expectation(description: "completion") + fetcher.request(route: FetchInAppGeoRoute()) { result in + switch result { + case .success: + XCTFail("Expected protocolError") + case .failure(let error): + guard case .protocolError(let pe) = error else { + XCTFail("Expected protocolError, got \(error)") + expectation.fulfill() + return + } + XCTAssertEqual(pe.httpStatusCode, 429) + } + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - 5xx + InternalServerError body → .serverError with decoded body + + func test_http500_statusInternalServerError_returnsServerError() throws { + let fetcher = try makeFetcher() + let body = protocolErrorData(status: "InternalServerError", message: "Temporary unavailability", httpCode: 500) + stubResponse(statusCode: 500, body: body) + + let expectation = expectation(description: "completion") + fetcher.request(route: FetchInAppGeoRoute()) { result in + switch result { + case .success: + XCTFail("Expected serverError") + case .failure(let error): + guard case .serverError(let pe) = error else { + XCTFail("Expected serverError, got \(error)") + expectation.fulfill() + return + } + XCTAssertEqual(pe.httpStatusCode, 500) + XCTAssertEqual(pe.errorMessage, "Temporary unavailability") + } + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - 502 with no status body → .serverError (generic) + + func test_http502_noStatusBody_returnsServerError() throws { + let fetcher = try makeFetcher() + let body = "Bad Gateway".data(using: .utf8) + stubResponse(statusCode: 502, body: body) + + let expectation = expectation(description: "completion") + fetcher.request(route: FetchInAppGeoRoute()) { result in + switch result { + case .success: + XCTFail("Expected serverError") + case .failure(let error): + guard case .serverError(let pe) = error else { + XCTFail("Expected serverError, got \(error)") + expectation.fulfill() + return + } + XCTAssertEqual(pe.httpStatusCode, 502) + } + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - 2xx + decode failure + emptyData → success + + func test_http200_decodeFail_emptyDataTrue_returnsSuccess() throws { + let fetcher = try makeFetcher() + let body = "not json".data(using: .utf8) + stubResponse(statusCode: 200, body: body) + + let expectation = expectation(description: "completion") + // Void request uses emptyData=true internally + fetcher.request(route: FetchInAppGeoRoute()) { result in + switch result { + case .success: + break // expected — emptyData=true swallows decode errors at 2xx + case .failure(let error): + XCTFail("Expected success with emptyData=true at 2xx, got \(error)") + } + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - needBaseResponse=false + HTTP 4xx → protocolError (NOT success) + + func test_needBaseResponseFalse_http403_returnsProtocolError() throws { + let fetcher = try makeFetcher() + let body = "Forbidden".data(using: .utf8) + stubResponse(statusCode: 403, body: body) + + let expectation = expectation(description: "completion") + fetcher.request(type: InAppGeoResponse.self, route: FetchInAppGeoRoute(), needBaseResponse: false) { result in + switch result { + case .success: + XCTFail("needBaseResponse=false + HTTP 403 should NOT be success") + case .failure(let error): + guard case .protocolError(let pe) = error else { + XCTFail("Expected protocolError, got \(error)") + expectation.fulfill() + return + } + XCTAssertEqual(pe.httpStatusCode, 403) + } + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - needBaseResponse=false + HTTP 2xx → success + + func test_needBaseResponseFalse_http200_returnsSuccess() throws { + let fetcher = try makeFetcher() + let model = InAppGeoResponse(city: 1, region: 2, country: 3) + let body = try JSONEncoder().encode(model) + stubResponse(statusCode: 200, body: body) + + let expectation = expectation(description: "completion") + fetcher.request(type: InAppGeoResponse.self, route: FetchInAppGeoRoute(), needBaseResponse: false) { result in + switch result { + case .success(let response): + XCTAssertEqual(response.city, 1) + case .failure(let error): + XCTFail("Expected success, got \(error)") + } + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - 3xx → invalidResponse + + func test_http301_returnsInvalidResponse() throws { + let fetcher = try makeFetcher() + let body = Data() + stubResponse(statusCode: 301, body: body) + + let expectation = expectation(description: "completion") + fetcher.request(route: FetchInAppGeoRoute()) { result in + switch result { + case .success: + XCTFail("Expected invalidResponse for 3xx") + case .failure(let error): + guard case .invalidResponse = error else { + XCTFail("Expected invalidResponse, got \(error)") + expectation.fulfill() + return + } + } + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - 2xx + ProtocolError status in body → invalidResponse + + func test_http200_statusProtocolError_returnsInvalidResponse() throws { + let fetcher = try makeFetcher() + let body = baseResponseData(status: "ProtocolError") + stubResponse(statusCode: 200, body: body) + + let expectation = expectation(description: "completion") + fetcher.request(route: FetchInAppGeoRoute()) { result in + switch result { + case .success: + XCTFail("Expected invalidResponse for 2xx + ProtocolError status") + case .failure(let error): + guard case .invalidResponse = error else { + XCTFail("Expected invalidResponse, got \(error)") + expectation.fulfill() + return + } + } + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - 2xx + InternalServerError status in body → invalidResponse + + func test_http200_statusInternalServerError_returnsInvalidResponse() throws { + let fetcher = try makeFetcher() + let body = baseResponseData(status: "InternalServerError") + stubResponse(statusCode: 200, body: body) + + let expectation = expectation(description: "completion") + fetcher.request(route: FetchInAppGeoRoute()) { result in + switch result { + case .success: + XCTFail("Expected invalidResponse for 2xx + InternalServerError status") + case .failure(let error): + guard case .invalidResponse = error else { + XCTFail("Expected invalidResponse, got \(error)") + expectation.fulfill() + return + } + } + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - 2xx + unknown status in body → invalidResponse + + func test_http200_statusUnknown_returnsInvalidResponse() throws { + let fetcher = try makeFetcher() + let body = baseResponseData(status: "SomethingUnexpected") + stubResponse(statusCode: 200, body: body) + + let expectation = expectation(description: "completion") + fetcher.request(route: FetchInAppGeoRoute()) { result in + switch result { + case .success: + XCTFail("Expected invalidResponse for 2xx + unknown status") + case .failure(let error): + guard case .invalidResponse = error else { + XCTFail("Expected invalidResponse, got \(error)") + expectation.fulfill() + return + } + } + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - 4xx + ValidationError status → .validationError + + func test_http400_statusValidationError_returnsValidationError() throws { + let fetcher = try makeFetcher() + let body = validationErrorData() + stubResponse(statusCode: 400, body: body) + + let expectation = expectation(description: "completion") + fetcher.request(route: FetchInAppGeoRoute()) { result in + switch result { + case .success: + XCTFail("Expected validationError") + case .failure(let error): + guard case .validationError = error else { + XCTFail("Expected validationError, got \(error)") + expectation.fulfill() + return + } + } + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - 4xx (non-404) + unparseable body → protocolError "Client error" + + func test_http400_unparseableBody_returnsProtocolErrorClientError() throws { + let fetcher = try makeFetcher() + let body = "not json".data(using: .utf8) + stubResponse(statusCode: 400, body: body) + + let expectation = expectation(description: "completion") + fetcher.request(route: FetchInAppGeoRoute()) { result in + switch result { + case .success: + XCTFail("Expected protocolError for 400 + unparseable body") + case .failure(let error): + guard case .protocolError(let pe) = error else { + XCTFail("Expected protocolError, got \(error)") + expectation.fulfill() + return + } + XCTAssertEqual(pe.httpStatusCode, 400) + XCTAssertEqual(pe.errorMessage, "Client error") + } + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - 403 + unparseable body → protocolError "Client error" + + func test_http403_unparseableBody_returnsProtocolErrorClientError() throws { + let fetcher = try makeFetcher() + let body = "Forbidden".data(using: .utf8) + stubResponse(statusCode: 403, body: body) + + let expectation = expectation(description: "completion") + fetcher.request(route: FetchInAppGeoRoute()) { result in + switch result { + case .success: + XCTFail("Expected protocolError for 403 + unparseable body") + case .failure(let error): + guard case .protocolError(let pe) = error else { + XCTFail("Expected protocolError, got \(error)") + expectation.fulfill() + return + } + XCTAssertEqual(pe.httpStatusCode, 403) + XCTAssertEqual(pe.errorMessage, "Client error") + } + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - 5xx + empty body → .serverError + + func test_http500_emptyBody_returnsServerError() throws { + let fetcher = try makeFetcher() + stubResponse(statusCode: 500, body: nil) + + let expectation = expectation(description: "completion") + fetcher.request(route: FetchInAppGeoRoute()) { result in + switch result { + case .success: + XCTFail("Expected serverError for 500 + nil data") + case .failure(let error): + guard case .serverError(let pe) = error else { + XCTFail("Expected serverError, got \(error)") + expectation.fulfill() + return + } + XCTAssertEqual(pe.httpStatusCode, 500) + } + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - 2xx + empty body + Void request (emptyData=true) → success + + func test_http200_emptyBody_voidRequest_returnsSuccess() throws { + let fetcher = try makeFetcher() + stubResponse(statusCode: 200, body: nil) + + let expectation = expectation(description: "completion") + fetcher.request(route: FetchInAppGeoRoute()) { result in + switch result { + case .success: + break // expected — emptyData=true at 2xx tolerates unparseable body + case .failure(let error): + XCTFail("Expected success for 200 + empty body + Void request, got \(error)") + } + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - 2xx + empty body + typed request (emptyData=false) → parsing error + + func test_http200_emptyBody_typedRequest_returnsParsingError() throws { + let fetcher = try makeFetcher() + stubResponse(statusCode: 200, body: nil) + + let expectation = expectation(description: "completion") + fetcher.request(type: InAppGeoResponse.self, route: FetchInAppGeoRoute()) { result in + switch result { + case .success: + XCTFail("Expected parsing error for 200 + empty body + typed request") + case .failure(let error): + guard case .internalError(let ie) = error else { + XCTFail("Expected internalError(.parsing), got \(error)") + expectation.fulfill() + return + } + XCTAssertEqual(ie.errorKey, ErrorKey.parsing.rawValue) + } + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - 503 + parseable body → .serverError with decoded message + + func test_http503_parseableBody_returnsServerErrorWithMessage() throws { + let fetcher = try makeFetcher() + let body = protocolErrorData(status: "InternalServerError", message: "Service Unavailable", httpCode: 503) + stubResponse(statusCode: 503, body: body) + + let expectation = expectation(description: "completion") + fetcher.request(route: FetchInAppGeoRoute()) { result in + switch result { + case .success: + XCTFail("Expected serverError") + case .failure(let error): + guard case .serverError(let pe) = error else { + XCTFail("Expected serverError, got \(error)") + expectation.fulfill() + return + } + XCTAssertEqual(pe.httpStatusCode, 503) + XCTAssertEqual(pe.errorMessage, "Service Unavailable") + } + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - Typed request + 200 + needBaseResponse=true + success body → decoded object + + func test_typedRequest_http200_statusSuccess_returnsDecodedObject() throws { + let fetcher = try makeFetcher() + let responseBody: [String: Any] = [ + "status": "Success", + "city_id": 10, + "region_id": 20, + "country_id": 30 + ] + // swiftlint:disable:next force_try + let body = try! JSONSerialization.data(withJSONObject: responseBody) + stubResponse(statusCode: 200, body: body) + + let expectation = expectation(description: "completion") + fetcher.request(type: InAppGeoResponse.self, route: FetchInAppGeoRoute()) { result in + switch result { + case .success(let response): + XCTAssertEqual(response.city, 10) + XCTAssertEqual(response.region, 20) + XCTAssertEqual(response.country, 30) + case .failure(let error): + XCTFail("Expected success, got \(error)") + } + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - Typed request + 200 + valid base response but invalid target type → parsing error + + func test_typedRequest_http200_invalidTargetType_returnsParsingError() throws { + let fetcher = try makeFetcher() + // Valid base response but missing fields for InAppGeoResponse + let body = baseResponseData(status: "Success") + stubResponse(statusCode: 200, body: body) + + let expectation = expectation(description: "completion") + fetcher.request(type: InAppGeoResponse.self, route: FetchInAppGeoRoute()) { result in + switch result { + case .success: + XCTFail("Expected parsing error for invalid target type") + case .failure(let error): + guard case .internalError(let ie) = error else { + XCTFail("Expected internalError(.parsing), got \(error)") + expectation.fulfill() + return + } + XCTAssertEqual(ie.errorKey, ErrorKey.parsing.rawValue) + } + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } +} + +// MARK: - Stub URL Protocol + +private final class StubURLProtocol: URLProtocol { + static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data?))? + + override class func canInit(with request: URLRequest) -> Bool { true } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard let handler = Self.requestHandler else { + client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + if let data = data { + client?.urlProtocol(self, didLoad: data) + } + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} From 484b45c49bc2fb235f3f5d150d95cd26c298068a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:01:38 +0300 Subject: [PATCH 02/24] Bump addressable from 2.8.9 to 2.9.0 (#694) Bumps [addressable](https://github.com/sporkmonger/addressable) from 2.8.9 to 2.9.0. - [Changelog](https://github.com/sporkmonger/addressable/blob/main/CHANGELOG.md) - [Commits](https://github.com/sporkmonger/addressable/compare/addressable-2.8.9...addressable-2.9.0) --- updated-dependencies: - dependency-name: addressable dependency-version: 2.9.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index dff293410..9f2a0f600 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -15,7 +15,7 @@ GEM minitest (>= 5.1, < 6) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) - addressable (2.8.9) + addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) algoliasearch (1.27.5) httpclient (~> 2.8, >= 2.8.3) From 747e7037b1f275795446bf0e849cb4aa8520e7f7 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:25:31 +0300 Subject: [PATCH 03/24] MOBILE-108: Migrate Example app from CocoaPods to SPM --- Example/.gitignore | 1 - Example/Example.xcodeproj/project.pbxproj | 166 ++++-------------- .../contents.xcworkspacedata | 10 -- .../xcshareddata/IDEWorkspaceChecks.plist | 8 - Example/Example/AppDelegate.swift | 6 +- Example/Podfile | 13 -- Example/README.md | 72 +++----- README.md | 60 +++++-- 8 files changed, 113 insertions(+), 223 deletions(-) delete mode 100644 Example/.gitignore delete mode 100644 Example/Example.xcworkspace/contents.xcworkspacedata delete mode 100644 Example/Example.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist delete mode 100644 Example/Podfile diff --git a/Example/.gitignore b/Example/.gitignore deleted file mode 100644 index 8f16b230b..000000000 --- a/Example/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/Podfile.lock \ No newline at end of file diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index 3e859eefd..5d5a0dcf8 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -23,18 +23,18 @@ 0AEDBC962BB6FE4900EE8722 /* MainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AEDBC952BB6FE4900EE8722 /* MainViewModel.swift */; }; 0AEDBC992BB7058B00EE8722 /* ButtonsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AEDBC982BB7058B00EE8722 /* ButtonsView.swift */; }; 0AEDBC9B2BB70D6E00EE8722 /* SDKDataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AEDBC9A2BB70D6E00EE8722 /* SDKDataView.swift */; }; - 43E1C73D7E87903545C2ACBC /* Pods_Example.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D9AF8ABB9430FE76D72FE17D /* Pods_Example.framework */; }; 472423992C185B8400B2A9BC /* Item+SwiftData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 472423982C185B8400B2A9BC /* Item+SwiftData.swift */; }; 474F37A22C16F5A700F38BB0 /* NotificationCenterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 474F37A12C16F5A700F38BB0 /* NotificationCenterViewModel.swift */; }; 474F37A42C16F5B000F38BB0 /* NotificationCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 474F37A32C16F5B000F38BB0 /* NotificationCenterView.swift */; }; 474F37AE2C170FB600F38BB0 /* NotificationCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 474F37AD2C170FB600F38BB0 /* NotificationCellView.swift */; }; 4774807A2C174BAA00580FB2 /* Payload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 477480792C174BAA00580FB2 /* Payload.swift */; }; + 47DB4FE82F868A6700A0AC53 /* Mindbox in Frameworks */ = {isa = PBXBuildFile; productRef = 47DB4FE72F868A6700A0AC53 /* Mindbox */; }; + 47DB4FEA2F868A6700A0AC53 /* MindboxNotificationsContent in Frameworks */ = {isa = PBXBuildFile; productRef = 47DB4FE92F868A6700A0AC53 /* MindboxNotificationsContent */; }; + 47DB4FEC2F868A6700A0AC53 /* MindboxNotificationsService in Frameworks */ = {isa = PBXBuildFile; productRef = 47DB4FEB2F868A6700A0AC53 /* MindboxNotificationsService */; }; 47FFE2232C187B650007E2F6 /* Item+SwiftData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 472423982C185B8400B2A9BC /* Item+SwiftData.swift */; }; 47FFE2262C187D3C0007E2F6 /* SwiftDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47FFE2252C187D3C0007E2F6 /* SwiftDataManager.swift */; }; 47FFE2282C187D5C0007E2F6 /* SwiftDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47FFE2252C187D3C0007E2F6 /* SwiftDataManager.swift */; }; 47FFE22C2C1886550007E2F6 /* Payload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 477480792C174BAA00580FB2 /* Payload.swift */; }; - 9E3108F96D4F26745D3B37A4 /* Pods_MindboxNotificationServiceExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4AF2BFD8ABA97C73F589C5DB /* Pods_MindboxNotificationServiceExtension.framework */; }; - D9585975AC05213E1682C760 /* Pods_MindboxNotificationContentExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 91EB2315545CE887D05CB505 /* Pods_MindboxNotificationContentExtension.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -93,7 +93,6 @@ 0AEDBC982BB7058B00EE8722 /* ButtonsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonsView.swift; sourceTree = ""; }; 0AEDBC9A2BB70D6E00EE8722 /* SDKDataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDKDataView.swift; sourceTree = ""; }; 0AEDBC9C2BB7177A00EE8722 /* Example.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Example.entitlements; sourceTree = ""; }; - 1B29989833584963EC18BDF2 /* Pods-MindboxNotificationContentExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MindboxNotificationContentExtension.release.xcconfig"; path = "Target Support Files/Pods-MindboxNotificationContentExtension/Pods-MindboxNotificationContentExtension.release.xcconfig"; sourceTree = ""; }; 472423982C185B8400B2A9BC /* Item+SwiftData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Item+SwiftData.swift"; sourceTree = ""; }; 474F37A12C16F5A700F38BB0 /* NotificationCenterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCenterViewModel.swift; sourceTree = ""; }; 474F37A32C16F5B000F38BB0 /* NotificationCenterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCenterView.swift; sourceTree = ""; }; @@ -102,14 +101,6 @@ 47912FD92CB42D640063387D /* AppDelegate_IDFA.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate_IDFA.swift; sourceTree = ""; }; 47D63E2C2C2EAD220055E7D8 /* Mindbox.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Mindbox.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 47FFE2252C187D3C0007E2F6 /* SwiftDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftDataManager.swift; sourceTree = ""; }; - 4AF2BFD8ABA97C73F589C5DB /* Pods_MindboxNotificationServiceExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MindboxNotificationServiceExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 4C06401BC9C282DDDDADEA9D /* Pods-Example.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Example.release.xcconfig"; path = "Target Support Files/Pods-Example/Pods-Example.release.xcconfig"; sourceTree = ""; }; - 91EB2315545CE887D05CB505 /* Pods_MindboxNotificationContentExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MindboxNotificationContentExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 98B661C604DA022990E36545 /* Pods-MindboxNotificationContentExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MindboxNotificationContentExtension.debug.xcconfig"; path = "Target Support Files/Pods-MindboxNotificationContentExtension/Pods-MindboxNotificationContentExtension.debug.xcconfig"; sourceTree = ""; }; - D9AF8ABB9430FE76D72FE17D /* Pods_Example.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Example.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - ED36675A98FBA2841D35F798 /* Pods-MindboxNotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MindboxNotificationServiceExtension.debug.xcconfig"; path = "Target Support Files/Pods-MindboxNotificationServiceExtension/Pods-MindboxNotificationServiceExtension.debug.xcconfig"; sourceTree = ""; }; - EE69A6D9C5531199626259C7 /* Pods-Example.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Example.debug.xcconfig"; path = "Target Support Files/Pods-Example/Pods-Example.debug.xcconfig"; sourceTree = ""; }; - FED8635B0CDC35B398DE425B /* Pods-MindboxNotificationServiceExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MindboxNotificationServiceExtension.release.xcconfig"; path = "Target Support Files/Pods-MindboxNotificationServiceExtension/Pods-MindboxNotificationServiceExtension.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -117,7 +108,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 9E3108F96D4F26745D3B37A4 /* Pods_MindboxNotificationServiceExtension.framework in Frameworks */, + 47DB4FEC2F868A6700A0AC53 /* MindboxNotificationsService in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -126,8 +117,8 @@ buildActionMask = 2147483647; files = ( 0AD271D22BB9D81E00750279 /* UserNotificationsUI.framework in Frameworks */, + 47DB4FEA2F868A6700A0AC53 /* MindboxNotificationsContent in Frameworks */, 0AD271D02BB9D81E00750279 /* UserNotifications.framework in Frameworks */, - D9585975AC05213E1682C760 /* Pods_MindboxNotificationContentExtension.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -135,7 +126,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 43E1C73D7E87903545C2ACBC /* Pods_Example.framework in Frameworks */, + 47DB4FE82F868A6700A0AC53 /* Mindbox in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -170,7 +161,6 @@ 0A0DE3462BB8455A00812E73 /* MindboxNotificationServiceExtension */, 0AD271D32BB9D81E00750279 /* MindboxNotificationContentExtension */, 0AEDBC772BB6F8F200EE8722 /* Products */, - D07217A390B79F6F292D4AFC /* Pods */, 52C896E46FFA4E326FC946A8 /* Frameworks */, ); sourceTree = ""; @@ -280,26 +270,10 @@ 47D63E2C2C2EAD220055E7D8 /* Mindbox.framework */, 0AD271CF2BB9D81D00750279 /* UserNotifications.framework */, 0AD271D12BB9D81E00750279 /* UserNotificationsUI.framework */, - D9AF8ABB9430FE76D72FE17D /* Pods_Example.framework */, - 91EB2315545CE887D05CB505 /* Pods_MindboxNotificationContentExtension.framework */, - 4AF2BFD8ABA97C73F589C5DB /* Pods_MindboxNotificationServiceExtension.framework */, ); name = Frameworks; sourceTree = ""; }; - D07217A390B79F6F292D4AFC /* Pods */ = { - isa = PBXGroup; - children = ( - EE69A6D9C5531199626259C7 /* Pods-Example.debug.xcconfig */, - 4C06401BC9C282DDDDADEA9D /* Pods-Example.release.xcconfig */, - 98B661C604DA022990E36545 /* Pods-MindboxNotificationContentExtension.debug.xcconfig */, - 1B29989833584963EC18BDF2 /* Pods-MindboxNotificationContentExtension.release.xcconfig */, - ED36675A98FBA2841D35F798 /* Pods-MindboxNotificationServiceExtension.debug.xcconfig */, - FED8635B0CDC35B398DE425B /* Pods-MindboxNotificationServiceExtension.release.xcconfig */, - ); - path = Pods; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -307,7 +281,6 @@ isa = PBXNativeTarget; buildConfigurationList = 0A0DE3502BB8455A00812E73 /* Build configuration list for PBXNativeTarget "MindboxNotificationServiceExtension" */; buildPhases = ( - B7B24093D38D92301E296825 /* [CP] Check Pods Manifest.lock */, 0A0DE3412BB8455A00812E73 /* Sources */, 0A0DE3422BB8455A00812E73 /* Frameworks */, 0A0DE3432BB8455A00812E73 /* Resources */, @@ -325,7 +298,6 @@ isa = PBXNativeTarget; buildConfigurationList = 0AD271DF2BB9D81E00750279 /* Build configuration list for PBXNativeTarget "MindboxNotificationContentExtension" */; buildPhases = ( - 329A78FBC74175587E16D3B8 /* [CP] Check Pods Manifest.lock */, 0AD271CA2BB9D81D00750279 /* Sources */, 0AD271CB2BB9D81D00750279 /* Frameworks */, 0AD271CC2BB9D81D00750279 /* Resources */, @@ -343,12 +315,10 @@ isa = PBXNativeTarget; buildConfigurationList = 0AEDBC8A2BB6F8F400EE8722 /* Build configuration list for PBXNativeTarget "Example" */; buildPhases = ( - 55EB7B86C51269267A62C674 /* [CP] Check Pods Manifest.lock */, 0AEDBC722BB6F8F200EE8722 /* Sources */, 0AEDBC732BB6F8F200EE8722 /* Frameworks */, 0AEDBC742BB6F8F200EE8722 /* Resources */, 0A0DE34D2BB8455A00812E73 /* Embed Foundation Extensions */, - 4AE040A30AF262E286C28F73 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -392,6 +362,9 @@ Base, ); mainGroup = 0AEDBC6D2BB6F8F200EE8722; + packageReferences = ( + 47DB4FE62F868A6700A0AC53 /* XCRemoteSwiftPackageReference "ios-sdk" */, + ); productRefGroup = 0AEDBC772BB6F8F200EE8722 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -430,92 +403,6 @@ }; /* End PBXResourcesBuildPhase section */ -/* Begin PBXShellScriptBuildPhase section */ - 329A78FBC74175587E16D3B8 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-MindboxNotificationContentExtension-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; - 4AE040A30AF262E286C28F73 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Example/Pods-Example-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Example/Pods-Example-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Example/Pods-Example-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - 55EB7B86C51269267A62C674 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Example-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; - B7B24093D38D92301E296825 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-MindboxNotificationServiceExtension-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; -/* End PBXShellScriptBuildPhase section */ - /* Begin PBXSourcesBuildPhase section */ 0A0DE3412BB8455A00812E73 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -574,7 +461,6 @@ /* Begin XCBuildConfiguration section */ 0A0DE34E2BB8455A00812E73 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = ED36675A98FBA2841D35F798 /* Pods-MindboxNotificationServiceExtension.debug.xcconfig */; buildSettings = { CODE_SIGN_ENTITLEMENTS = MindboxNotificationServiceExtension/MindboxNotificationServiceExtension.entitlements; CODE_SIGN_STYLE = Automatic; @@ -607,7 +493,6 @@ }; 0A0DE34F2BB8455A00812E73 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = FED8635B0CDC35B398DE425B /* Pods-MindboxNotificationServiceExtension.release.xcconfig */; buildSettings = { CODE_SIGN_ENTITLEMENTS = MindboxNotificationServiceExtension/MindboxNotificationServiceExtension.entitlements; CODE_SIGN_STYLE = Automatic; @@ -640,7 +525,6 @@ }; 0AD271DD2BB9D81E00750279 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 98B661C604DA022990E36545 /* Pods-MindboxNotificationContentExtension.debug.xcconfig */; buildSettings = { CODE_SIGN_ENTITLEMENTS = MindboxNotificationContentExtension/MindboxNotificationContentExtension.entitlements; CODE_SIGN_STYLE = Automatic; @@ -668,7 +552,6 @@ }; 0AD271DE2BB9D81E00750279 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 1B29989833584963EC18BDF2 /* Pods-MindboxNotificationContentExtension.release.xcconfig */; buildSettings = { CODE_SIGN_ENTITLEMENTS = MindboxNotificationContentExtension/MindboxNotificationContentExtension.entitlements; CODE_SIGN_STYLE = Automatic; @@ -815,7 +698,6 @@ }; 0AEDBC8B2BB6F8F400EE8722 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = EE69A6D9C5531199626259C7 /* Pods-Example.debug.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; @@ -849,7 +731,6 @@ }; 0AEDBC8C2BB6F8F400EE8722 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 4C06401BC9C282DDDDADEA9D /* Pods-Example.release.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; @@ -921,6 +802,35 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 47DB4FE62F868A6700A0AC53 /* XCRemoteSwiftPackageReference "ios-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/mindbox-cloud/ios-sdk"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.15.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 47DB4FE72F868A6700A0AC53 /* Mindbox */ = { + isa = XCSwiftPackageProductDependency; + package = 47DB4FE62F868A6700A0AC53 /* XCRemoteSwiftPackageReference "ios-sdk" */; + productName = Mindbox; + }; + 47DB4FE92F868A6700A0AC53 /* MindboxNotificationsContent */ = { + isa = XCSwiftPackageProductDependency; + package = 47DB4FE62F868A6700A0AC53 /* XCRemoteSwiftPackageReference "ios-sdk" */; + productName = MindboxNotificationsContent; + }; + 47DB4FEB2F868A6700A0AC53 /* MindboxNotificationsService */ = { + isa = XCSwiftPackageProductDependency; + package = 47DB4FE62F868A6700A0AC53 /* XCRemoteSwiftPackageReference "ios-sdk" */; + productName = MindboxNotificationsService; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 0AEDBC6E2BB6F8F200EE8722 /* Project object */; } diff --git a/Example/Example.xcworkspace/contents.xcworkspacedata b/Example/Example.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index a37cf193d..000000000 --- a/Example/Example.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - diff --git a/Example/Example.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Example/Example.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d981003..000000000 --- a/Example/Example.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/Example/Example/AppDelegate.swift b/Example/Example/AppDelegate.swift index 9c4ec6d5d..dd2331467 100644 --- a/Example/Example/AppDelegate.swift +++ b/Example/Example/AppDelegate.swift @@ -40,7 +40,7 @@ final class AppDelegate: MindboxAppDelegate { return true } - // https://developers.mindbox.ru/docs/ios-send-push-notifications-appdelegate + // https://developers.mindbox.ru/docs/ios-quick-setup-push-notifications func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, @@ -49,7 +49,7 @@ final class AppDelegate: MindboxAppDelegate { completionHandler([.list, .badge, .sound, .banner]) } - // https://developers.mindbox.ru/docs/ios-sdk-handle-tap + // https://developers.mindbox.ru/docs/ios-push-notification-deep-linking override func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, @@ -66,7 +66,7 @@ final class AppDelegate: MindboxAppDelegate { super.userNotificationCenter(center, didReceive: response, withCompletionHandler: completionHandler) } - // https://developers.mindbox.ru/docs/ios-send-push-notifications-appdelegate + // https://developers.mindbox.ru/docs/ios-quick-setup-push-notifications func registerForRemoteNotifications() { UNUserNotificationCenter.current().delegate = self DispatchQueue.main.async { diff --git a/Example/Podfile b/Example/Podfile deleted file mode 100644 index fdea3cfd3..000000000 --- a/Example/Podfile +++ /dev/null @@ -1,13 +0,0 @@ -use_frameworks! - -target 'Example' do - pod 'Mindbox' -end - -target 'MindboxNotificationServiceExtension' do - pod 'MindboxNotifications' -end - -target 'MindboxNotificationContentExtension' do - pod 'MindboxNotifications' -end diff --git a/Example/README.md b/Example/README.md index a3fef6a62..ad50980f4 100644 --- a/Example/README.md +++ b/Example/README.md @@ -1,65 +1,47 @@ -# Example app with SPM for Mindbox SDK for iOS +# Example app for Mindbox SDK for iOS -This is an example of SDK [integration](https://developers.mindbox.ru/docs/ios-sdk-integration) +This is an example of SDK [integration](https://developers.mindbox.ru/docs/ios-sdk). ## Getting started ### Launching the application -The app has integration via cocoapods, but you can use SPM if you need. -#### Cocoapods: + +The app uses SPM to integrate the Mindbox SDK. Xcode will resolve and download the dependency when you open the project. + 1. [Clone ios-sdk repository](https://github.com/mindbox-cloud/ios-sdk). -2. Make sure you have CocoaPods installed or install it according to the instructions. -3. Go to `ios-sdk/Example/` -4. Install the pods. - ```ruby - pod update - ``` - Or - ```ruby - pod install - ``` -5. Go to `ios-sdk/Example/Example.xcworkspace`. -6. Run file `Example.xcworkspace`. -#### SPM: -1. To deintegrate cocoapods you can use next comands: - - `sudo gem install cocoapods-deintegrate` - - `pod deintegrate` - - `rm Podfile` - - `rm Podfile.lock` - - `rm Example.xcworkspace` - - `rm -rf Pods` -2. Launch `Example.xcodeproj`. -3. [Read this](https://developers.mindbox.ru/docs/add-ios-sdk) and follow the initialization instructions via SPM. +2. Open `ios-sdk/Example/Example.xcodeproj`. +3. To ensure you have the latest SDK version: **File → Packages → Update to Latest Package Versions**. +4. Build and run. -Now you can test the in-app on the simulator. -In our admin panel there are already 3 ready-made in-apps that you can look at. +Now you can test the in-app on the simulator. +In our admin panel there are already 3 ready-made in-apps that you can look at. To run the application on a real device and try push notifications, follow the instructions below. -### Setting up a Example application with your personal account (to run on a real device) +### Setting up the Example application with your personal account (to run on a real device) 1. Change [team](https://developers.mindbox.ru/docs/ios-get-keys) and bundle identifiers and App Group name for next targets: - - ExampleApp - - MindboxNotificationServiceExtension - - MindboxNotificationContentExtension + - ExampleApp + - MindboxNotificationServiceExtension + - MindboxNotificationContentExtension 2. [Configure your endpoints](https://developers.mindbox.ru/docs/add-ios-integration). 3. Change domain and endpoints in the `AppDelegate.swift` to yours. ### SDK functionality testing -1. To check innap when opening: - - [Read this](https://help.mindbox.ru/docs/in-app-what-is). - - Open app. -2. To check the inapp anywhere in the application: - - [Read this](https://help.mindbox.ru/docs/in-app-location). - - Replace `operationSystemName` in `showInAppWithExecuteSyncOperation` and `showInAppWithExecuteAsyncOperation` in MainViewModel. - - Click to the button `Show in-app` opposite the selected operation. +1. To check in-app when opening: + - [Read this](https://help.mindbox.ru/docs/in-apps). + - Open app. +2. To check the in-app anywhere in the application: + - [Read this](https://help.mindbox.ru/docs/in-app-location). + - Replace `operationSystemName` in `showInAppWithExecuteSyncOperation` and `showInAppWithExecuteAsyncOperation` in MainViewModel. + - Click to the button `Show in-app` opposite the selected operation. 3. To check push notifications: - - [Read this](https://developers.mindbox.ru/docs/ios-send-push-notifications-advanced) - - Send a notification from your account. + - [Read this](https://developers.mindbox.ru/docs/mobile-push-check) + - Send a notification from your account. 4. To check rich notifications: - - [Read this](https://developers.mindbox.ru/docs/ios-send-push-notifications-advanced) - - Send a notification from your account. + - [Read this](https://developers.mindbox.ru/docs/mobile-push-check) + - Send a notification from your account. ### Additionally - - Currently the In-App only comes once per session. - - There are comments and links in the ExampleApp code that can help you. +- Currently the In-App only comes once per session. +- There are comments and links in the ExampleApp code that can help you. diff --git a/README.md b/README.md index af7b428e6..c404bcc26 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![CocoaPods Compatible](https://img.shields.io/cocoapods/v/Mindbox.svg)](https://cocoapods.org/pods/Mindbox) +[![Swift Package Manager Compatible](https://img.shields.io/badge/SPM-compatible-brightgreen.svg)](https://swiftpackageindex.com/mindbox-cloud/ios-sdk) # Mindbox SDK for iOS @@ -8,20 +8,50 @@ The Mindbox SDK allows developers to integrate mobile push notifications, in-app These instructions will help you integrate the Mindbox SDK into your iOS app. -### Installation +### Installation (Swift Package Manager — recommended) -Follow the installation process detailed [here](https://developers.mindbox.ru/docs/ios-sdk-integration). Overview: +Follow the installation process detailed [here](https://developers.mindbox.ru/docs/ios-sdk). Overview: -1. Add the Mindbox SDK to your Podfile: - ```markdown - pod 'Mindbox' - pod 'MindboxNotifications' - ``` +1. In Xcode, go to **File → Add Package Dependencies...** +2. Enter the repository URL: + ``` + https://github.com/mindbox-cloud/ios-sdk + ``` +3. Add `Mindbox` to your main app target. +4. Add `MindboxNotificationsService` to your Notification Service Extension target. +5. Add `MindboxNotificationsContent` to your Notification Content Extension target. -2. Install the pods: - ```markdown - pod install - ``` +### Installation (CocoaPods) + +CocoaPods is still supported. Add to your Podfile: + +```ruby +pod 'Mindbox' +pod 'MindboxNotifications' +``` + +### Migrating from CocoaPods to SPM + +If you are currently using CocoaPods and want to switch to SPM: + +1. Deintegrate CocoaPods: + ```bash + pod deintegrate + rm -rf Pods Podfile Podfile.lock + ``` + If your `.xcworkspace` was created by CocoaPods (contains only your project and `Pods.xcodeproj`), remove it as well: + ```bash + rm -rf YourApp.xcworkspace + ``` +2. Open your `.xcodeproj` (not `.xcworkspace`). +3. Verify no CocoaPods remnants: + - Each target → **Build Phases** → no `[CP]` phases should remain. + - Each target → **General** → **Frameworks, Libraries** → no `Pods_*.framework`. + - Project → **Build Settings** → search "Pods" → no xcconfig references. +4. Add the SDK via SPM (see Installation above). +5. Build and verify all targets compile. + +> **Note:** Your app data is not affected by this migration. Core Data stores are saved in the App Group container independently of the dependency manager. ### Initialization @@ -29,11 +59,11 @@ Initialize the Mindbox SDK in your AppDelegate or SceneDelegate. Refer to the do ### Operations -Learn how to send events to Mindbox. Create a new Operation class object and set the respective parameters. Check the [documentation](https://developers.mindbox.ru/docs/ios-integration-of-actions) for more details. +Learn how to send events to Mindbox. Create a new Operation class object and set the respective parameters. Check the [documentation](https://developers.mindbox.ru/docs/ios-sdk-events) for more details. ### Push Notifications -Mindbox SDK aids in handling push notifications. Configuration and usage instructions can be found in the SDK documentation [here](https://developers.mindbox.ru/docs/ios-send-push-notifications-appdelegate) and [here](https://developers.mindbox.ru/docs/ios-send-rich-push-appdelegate). +Mindbox SDK aids in handling push notifications. Configuration and usage instructions can be found in the SDK documentation [here](https://developers.mindbox.ru/docs/ios-quick-setup-push-notifications) and [here](https://developers.mindbox.ru/docs/ios-rich-push-notifications). ## Troubleshooting @@ -47,4 +77,4 @@ In need of further assistance? Feel free to contact us. This library is available as open source under the explicit terms of the [License](https://github.com/mindbox-cloud/ios-sdk/blob/develop/LICENSE.md). -For a better understanding of these content, we suggest reading the referenced [iOS SDK](https://developers.mindbox.ru/docs/ios-sdk-integration) documentation. +For a better understanding of these content, we suggest reading the referenced [iOS SDK](https://developers.mindbox.ru/docs/ios-sdk) documentation. From a88da71540a6b0b762c01aba7491354c5de17eec Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:45:56 +0300 Subject: [PATCH 04/24] MOBILE-108: Reorganize Example app from type-based to feature-based grouping --- Example/Example.xcodeproj/project.pbxproj | 336 ++++++------------ Example/Example/{ => App}/AppDelegate.swift | 0 Example/Example/{Views => App}/MainView.swift | 0 .../{ViewModels => App}/MainViewModel.swift | 0 .../CustomViews => App}/SDKDataView.swift | 0 Example/Example/{ => App}/SceneDelegate.swift | 0 .../ButtonsView.swift | 0 .../ChooseInAppMessagesDelegate.swift | 0 Example/Example/Models/Payload.swift | 14 - .../NotificationCenterView.swift | 48 ++- .../NotificationCenterViewModel.swift | 0 .../NotificationModels.swift} | 11 +- .../SwiftDataManager.swift | 0 .../NotificationCellView.swift | 41 --- 14 files changed, 164 insertions(+), 286 deletions(-) rename Example/Example/{ => App}/AppDelegate.swift (100%) rename Example/Example/{Views => App}/MainView.swift (100%) rename Example/Example/{ViewModels => App}/MainViewModel.swift (100%) rename Example/Example/{Views/CustomViews => App}/SDKDataView.swift (100%) rename Example/Example/{ => App}/SceneDelegate.swift (100%) rename Example/Example/{Views/CustomViews => InAppMessages}/ButtonsView.swift (100%) rename Example/Example/{ViewModels/ChooseInAppMessagesDelegate => InAppMessages}/ChooseInAppMessagesDelegate.swift (100%) delete mode 100644 Example/Example/Models/Payload.swift rename Example/Example/{Views/NotificationCenterViews => NotificationCenter}/NotificationCenterView.swift (71%) rename Example/Example/{ViewModels => NotificationCenter}/NotificationCenterViewModel.swift (100%) rename Example/Example/{Models/Item+SwiftData.swift => NotificationCenter/NotificationModels.swift} (88%) rename Example/Example/{Services => NotificationCenter}/SwiftDataManager.swift (100%) delete mode 100644 Example/Example/Views/NotificationCenterViews/NotificationCellView.swift diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index 5d5a0dcf8..b15baa06f 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -3,38 +3,17 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ - 0A0DE3482BB8455A00812E73 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A0DE3472BB8455A00812E73 /* NotificationService.swift */; }; - 0A0DE34C2BB8455A00812E73 /* MindboxNotificationServiceExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0A0DE3452BB8455A00812E73 /* MindboxNotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 0A4B681A2BBC82B500639BC5 /* ChooseInAppMessagesDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A4B68192BBC82B500639BC5 /* ChooseInAppMessagesDelegate.swift */; }; - 0A4B682A2BBD7D5100639BC5 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0A4B68292BBD7D5100639BC5 /* LaunchScreen.storyboard */; }; + 0A0DE34C2BB8455A00812E73 /* MindboxNotificationServiceExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 47F7FFDB2F87C02A0058A954 /* MindboxNotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 0AD271D02BB9D81E00750279 /* UserNotifications.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0AD271CF2BB9D81D00750279 /* UserNotifications.framework */; }; 0AD271D22BB9D81E00750279 /* UserNotificationsUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0AD271D12BB9D81E00750279 /* UserNotificationsUI.framework */; }; - 0AD271D52BB9D81E00750279 /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AD271D42BB9D81E00750279 /* NotificationViewController.swift */; }; - 0AD271DC2BB9D81E00750279 /* MindboxNotificationContentExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0AD271CE2BB9D81D00750279 /* MindboxNotificationContentExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 0AD271E42BBAAA8800750279 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 0AD271E32BBAAA8800750279 /* PrivacyInfo.xcprivacy */; }; - 0AEDBC7A2BB6F8F200EE8722 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AEDBC792BB6F8F200EE8722 /* AppDelegate.swift */; }; - 0AEDBC7C2BB6F8F200EE8722 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AEDBC7B2BB6F8F200EE8722 /* SceneDelegate.swift */; }; - 0AEDBC832BB6F8F400EE8722 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0AEDBC822BB6F8F400EE8722 /* Assets.xcassets */; }; - 0AEDBC922BB6FA4800EE8722 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AEDBC912BB6FA4800EE8722 /* MainView.swift */; }; - 0AEDBC962BB6FE4900EE8722 /* MainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AEDBC952BB6FE4900EE8722 /* MainViewModel.swift */; }; - 0AEDBC992BB7058B00EE8722 /* ButtonsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AEDBC982BB7058B00EE8722 /* ButtonsView.swift */; }; - 0AEDBC9B2BB70D6E00EE8722 /* SDKDataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AEDBC9A2BB70D6E00EE8722 /* SDKDataView.swift */; }; - 472423992C185B8400B2A9BC /* Item+SwiftData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 472423982C185B8400B2A9BC /* Item+SwiftData.swift */; }; - 474F37A22C16F5A700F38BB0 /* NotificationCenterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 474F37A12C16F5A700F38BB0 /* NotificationCenterViewModel.swift */; }; - 474F37A42C16F5B000F38BB0 /* NotificationCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 474F37A32C16F5B000F38BB0 /* NotificationCenterView.swift */; }; - 474F37AE2C170FB600F38BB0 /* NotificationCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 474F37AD2C170FB600F38BB0 /* NotificationCellView.swift */; }; - 4774807A2C174BAA00580FB2 /* Payload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 477480792C174BAA00580FB2 /* Payload.swift */; }; - 47DB4FE82F868A6700A0AC53 /* Mindbox in Frameworks */ = {isa = PBXBuildFile; productRef = 47DB4FE72F868A6700A0AC53 /* Mindbox */; }; - 47DB4FEA2F868A6700A0AC53 /* MindboxNotificationsContent in Frameworks */ = {isa = PBXBuildFile; productRef = 47DB4FE92F868A6700A0AC53 /* MindboxNotificationsContent */; }; - 47DB4FEC2F868A6700A0AC53 /* MindboxNotificationsService in Frameworks */ = {isa = PBXBuildFile; productRef = 47DB4FEB2F868A6700A0AC53 /* MindboxNotificationsService */; }; - 47FFE2232C187B650007E2F6 /* Item+SwiftData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 472423982C185B8400B2A9BC /* Item+SwiftData.swift */; }; - 47FFE2262C187D3C0007E2F6 /* SwiftDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47FFE2252C187D3C0007E2F6 /* SwiftDataManager.swift */; }; - 47FFE2282C187D5C0007E2F6 /* SwiftDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47FFE2252C187D3C0007E2F6 /* SwiftDataManager.swift */; }; - 47FFE22C2C1886550007E2F6 /* Payload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 477480792C174BAA00580FB2 /* Payload.swift */; }; + 0AD271DC2BB9D81E00750279 /* MindboxNotificationContentExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 47F7FFDC2F87C02A0058A954 /* MindboxNotificationContentExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 47F703BE2F87C5BA0058A954 /* Mindbox in Frameworks */ = {isa = PBXBuildFile; productRef = 47F703BD2F87C5BA0058A954 /* Mindbox */; }; + 47F703C02F87C5BA0058A954 /* MindboxNotificationsContent in Frameworks */ = {isa = PBXBuildFile; productRef = 47F703BF2F87C5BA0058A954 /* MindboxNotificationsContent */; }; + 47F703C22F87C5BA0058A954 /* MindboxNotificationsService in Frameworks */ = {isa = PBXBuildFile; productRef = 47F703C12F87C5BA0058A954 /* MindboxNotificationsService */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -70,45 +49,84 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 0A0DE3452BB8455A00812E73 /* MindboxNotificationServiceExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MindboxNotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; - 0A0DE3472BB8455A00812E73 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; - 0A0DE3492BB8455A00812E73 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 0A0DE3512BB8457100812E73 /* MindboxNotificationServiceExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MindboxNotificationServiceExtension.entitlements; sourceTree = ""; }; - 0A4B68192BBC82B500639BC5 /* ChooseInAppMessagesDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChooseInAppMessagesDelegate.swift; sourceTree = ""; }; - 0A4B68292BBD7D5100639BC5 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; - 0AD271CE2BB9D81D00750279 /* MindboxNotificationContentExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MindboxNotificationContentExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 0AD271CF2BB9D81D00750279 /* UserNotifications.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UserNotifications.framework; path = System/Library/Frameworks/UserNotifications.framework; sourceTree = SDKROOT; }; 0AD271D12BB9D81E00750279 /* UserNotificationsUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UserNotificationsUI.framework; path = System/Library/Frameworks/UserNotificationsUI.framework; sourceTree = SDKROOT; }; - 0AD271D42BB9D81E00750279 /* NotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewController.swift; sourceTree = ""; }; - 0AD271D92BB9D81E00750279 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 0AD271E02BB9D84800750279 /* MindboxNotificationContentExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MindboxNotificationContentExtension.entitlements; sourceTree = ""; }; - 0AD271E32BBAAA8800750279 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; - 0AEDBC762BB6F8F200EE8722 /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 0AEDBC792BB6F8F200EE8722 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 0AEDBC7B2BB6F8F200EE8722 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; - 0AEDBC822BB6F8F400EE8722 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 0AEDBC872BB6F8F400EE8722 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 0AEDBC912BB6FA4800EE8722 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; - 0AEDBC952BB6FE4900EE8722 /* MainViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewModel.swift; sourceTree = ""; }; - 0AEDBC982BB7058B00EE8722 /* ButtonsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonsView.swift; sourceTree = ""; }; - 0AEDBC9A2BB70D6E00EE8722 /* SDKDataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDKDataView.swift; sourceTree = ""; }; - 0AEDBC9C2BB7177A00EE8722 /* Example.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Example.entitlements; sourceTree = ""; }; - 472423982C185B8400B2A9BC /* Item+SwiftData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Item+SwiftData.swift"; sourceTree = ""; }; - 474F37A12C16F5A700F38BB0 /* NotificationCenterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCenterViewModel.swift; sourceTree = ""; }; - 474F37A32C16F5B000F38BB0 /* NotificationCenterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCenterView.swift; sourceTree = ""; }; - 474F37AD2C170FB600F38BB0 /* NotificationCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCellView.swift; sourceTree = ""; }; - 477480792C174BAA00580FB2 /* Payload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Payload.swift; sourceTree = ""; }; - 47912FD92CB42D640063387D /* AppDelegate_IDFA.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate_IDFA.swift; sourceTree = ""; }; - 47D63E2C2C2EAD220055E7D8 /* Mindbox.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Mindbox.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 47FFE2252C187D3C0007E2F6 /* SwiftDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftDataManager.swift; sourceTree = ""; }; + 47F7FFDA2F87C02A0058A954 /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 47F7FFDB2F87C02A0058A954 /* MindboxNotificationServiceExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MindboxNotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 47F7FFDC2F87C02A0058A954 /* MindboxNotificationContentExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MindboxNotificationContentExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 47F7FC3D2F87B94B0058A954 /* Exceptions for "Example" folder in "Example" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 0AEDBC752BB6F8F200EE8722 /* Example */; + }; + 47F7FC3E2F87B94B0058A954 /* Exceptions for "Example" folder in "MindboxNotificationServiceExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + NotificationCenter/NotificationModels.swift, + NotificationCenter/SwiftDataManager.swift, + ); + target = 0A0DE3442BB8455A00812E73 /* MindboxNotificationServiceExtension */; + }; + 47F7FC462F87B99C0058A954 /* Exceptions for "MindboxNotificationServiceExtension" folder in "MindboxNotificationServiceExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 0A0DE3442BB8455A00812E73 /* MindboxNotificationServiceExtension */; + }; + 47F7FC4C2F87B99F0058A954 /* Exceptions for "MindboxNotificationContentExtension" folder in "MindboxNotificationContentExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 0AD271CD2BB9D81D00750279 /* MindboxNotificationContentExtension */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 47F7FC2B2F87B94A0058A954 /* Example */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 47F7FC3D2F87B94B0058A954 /* Exceptions for "Example" folder in "Example" target */, + 47F7FC3E2F87B94B0058A954 /* Exceptions for "Example" folder in "MindboxNotificationServiceExtension" target */, + ); + path = Example; + sourceTree = ""; + }; + 47F7FC402F87B9580058A954 /* Samples */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Samples; + sourceTree = ""; + }; + 47F7FC442F87B99B0058A954 /* MindboxNotificationServiceExtension */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 47F7FC462F87B99C0058A954 /* Exceptions for "MindboxNotificationServiceExtension" folder in "MindboxNotificationServiceExtension" target */, + ); + path = MindboxNotificationServiceExtension; + sourceTree = ""; + }; + 47F7FC4A2F87B99E0058A954 /* MindboxNotificationContentExtension */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 47F7FC4C2F87B99F0058A954 /* Exceptions for "MindboxNotificationContentExtension" folder in "MindboxNotificationContentExtension" target */, + ); + path = MindboxNotificationContentExtension; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 0A0DE3422BB8455A00812E73 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 47DB4FEC2F868A6700A0AC53 /* MindboxNotificationsService in Frameworks */, + 47F703C22F87C5BA0058A954 /* MindboxNotificationsService in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -117,7 +135,7 @@ buildActionMask = 2147483647; files = ( 0AD271D22BB9D81E00750279 /* UserNotificationsUI.framework in Frameworks */, - 47DB4FEA2F868A6700A0AC53 /* MindboxNotificationsContent in Frameworks */, + 47F703C02F87C5BA0058A954 /* MindboxNotificationsContent in Frameworks */, 0AD271D02BB9D81E00750279 /* UserNotifications.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -126,40 +144,20 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 47DB4FE82F868A6700A0AC53 /* Mindbox in Frameworks */, + 47F703BE2F87C5BA0058A954 /* Mindbox in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 0A0DE3462BB8455A00812E73 /* MindboxNotificationServiceExtension */ = { - isa = PBXGroup; - children = ( - 0A0DE3512BB8457100812E73 /* MindboxNotificationServiceExtension.entitlements */, - 0A0DE3472BB8455A00812E73 /* NotificationService.swift */, - 0A0DE3492BB8455A00812E73 /* Info.plist */, - ); - path = MindboxNotificationServiceExtension; - sourceTree = ""; - }; - 0AD271D32BB9D81E00750279 /* MindboxNotificationContentExtension */ = { - isa = PBXGroup; - children = ( - 0AD271E02BB9D84800750279 /* MindboxNotificationContentExtension.entitlements */, - 0AD271D42BB9D81E00750279 /* NotificationViewController.swift */, - 0AD271D92BB9D81E00750279 /* Info.plist */, - ); - path = MindboxNotificationContentExtension; - sourceTree = ""; - }; 0AEDBC6D2BB6F8F200EE8722 = { isa = PBXGroup; children = ( - 47912FD82CB4130C0063387D /* Samples */, - 0AEDBC782BB6F8F200EE8722 /* Example */, - 0A0DE3462BB8455A00812E73 /* MindboxNotificationServiceExtension */, - 0AD271D32BB9D81E00750279 /* MindboxNotificationContentExtension */, + 47F7FC402F87B9580058A954 /* Samples */, + 47F7FC2B2F87B94A0058A954 /* Example */, + 47F7FC442F87B99B0058A954 /* MindboxNotificationServiceExtension */, + 47F7FC4A2F87B99E0058A954 /* MindboxNotificationContentExtension */, 0AEDBC772BB6F8F200EE8722 /* Products */, 52C896E46FFA4E326FC946A8 /* Frameworks */, ); @@ -168,106 +166,16 @@ 0AEDBC772BB6F8F200EE8722 /* Products */ = { isa = PBXGroup; children = ( - 0AEDBC762BB6F8F200EE8722 /* Example.app */, - 0A0DE3452BB8455A00812E73 /* MindboxNotificationServiceExtension.appex */, - 0AD271CE2BB9D81D00750279 /* MindboxNotificationContentExtension.appex */, + 47F7FFDA2F87C02A0058A954 /* Example.app */, + 47F7FFDB2F87C02A0058A954 /* MindboxNotificationServiceExtension.appex */, + 47F7FFDC2F87C02A0058A954 /* MindboxNotificationContentExtension.appex */, ); name = Products; sourceTree = ""; }; - 0AEDBC782BB6F8F200EE8722 /* Example */ = { - isa = PBXGroup; - children = ( - 0AEDBC9C2BB7177A00EE8722 /* Example.entitlements */, - 0AEDBC792BB6F8F200EE8722 /* AppDelegate.swift */, - 0AEDBC7B2BB6F8F200EE8722 /* SceneDelegate.swift */, - 0AD271E32BBAAA8800750279 /* PrivacyInfo.xcprivacy */, - 0AEDBC822BB6F8F400EE8722 /* Assets.xcassets */, - 0A4B68292BBD7D5100639BC5 /* LaunchScreen.storyboard */, - 0AEDBC872BB6F8F400EE8722 /* Info.plist */, - 47FFE2242C187D360007E2F6 /* Services */, - 474F37AF2C1710BB00F38BB0 /* Models */, - 0AEDBC9D2BB71FC600EE8722 /* Views */, - 0AEDBC9E2BB71FD700EE8722 /* ViewModels */, - ); - path = Example; - sourceTree = ""; - }; - 0AEDBC972BB7050F00EE8722 /* CustomViews */ = { - isa = PBXGroup; - children = ( - 0AEDBC982BB7058B00EE8722 /* ButtonsView.swift */, - 0AEDBC9A2BB70D6E00EE8722 /* SDKDataView.swift */, - ); - path = CustomViews; - sourceTree = ""; - }; - 0AEDBC9D2BB71FC600EE8722 /* Views */ = { - isa = PBXGroup; - children = ( - 0AEDBC972BB7050F00EE8722 /* CustomViews */, - 474F37AC2C170FA600F38BB0 /* NotificationCenterViews */, - 0AEDBC912BB6FA4800EE8722 /* MainView.swift */, - ); - path = Views; - sourceTree = ""; - }; - 0AEDBC9E2BB71FD700EE8722 /* ViewModels */ = { - isa = PBXGroup; - children = ( - 477480842C1760BE00580FB2 /* ChooseInAppMessagesDelegate */, - 0AEDBC952BB6FE4900EE8722 /* MainViewModel.swift */, - 474F37A12C16F5A700F38BB0 /* NotificationCenterViewModel.swift */, - ); - path = ViewModels; - sourceTree = ""; - }; - 474F37AC2C170FA600F38BB0 /* NotificationCenterViews */ = { - isa = PBXGroup; - children = ( - 474F37AD2C170FB600F38BB0 /* NotificationCellView.swift */, - 474F37A32C16F5B000F38BB0 /* NotificationCenterView.swift */, - ); - path = NotificationCenterViews; - sourceTree = ""; - }; - 474F37AF2C1710BB00F38BB0 /* Models */ = { - isa = PBXGroup; - children = ( - 477480792C174BAA00580FB2 /* Payload.swift */, - 472423982C185B8400B2A9BC /* Item+SwiftData.swift */, - ); - path = Models; - sourceTree = ""; - }; - 477480842C1760BE00580FB2 /* ChooseInAppMessagesDelegate */ = { - isa = PBXGroup; - children = ( - 0A4B68192BBC82B500639BC5 /* ChooseInAppMessagesDelegate.swift */, - ); - path = ChooseInAppMessagesDelegate; - sourceTree = ""; - }; - 47912FD82CB4130C0063387D /* Samples */ = { - isa = PBXGroup; - children = ( - 47912FD92CB42D640063387D /* AppDelegate_IDFA.swift */, - ); - path = Samples; - sourceTree = ""; - }; - 47FFE2242C187D360007E2F6 /* Services */ = { - isa = PBXGroup; - children = ( - 47FFE2252C187D3C0007E2F6 /* SwiftDataManager.swift */, - ); - path = Services; - sourceTree = ""; - }; 52C896E46FFA4E326FC946A8 /* Frameworks */ = { isa = PBXGroup; children = ( - 47D63E2C2C2EAD220055E7D8 /* Mindbox.framework */, 0AD271CF2BB9D81D00750279 /* UserNotifications.framework */, 0AD271D12BB9D81E00750279 /* UserNotificationsUI.framework */, ); @@ -289,9 +197,15 @@ ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + 47F7FC442F87B99B0058A954 /* MindboxNotificationServiceExtension */, + ); name = MindboxNotificationServiceExtension; + packageProductDependencies = ( + 47F703C12F87C5BA0058A954 /* MindboxNotificationsService */, + ); productName = MindboxNotificationServiceExtension; - productReference = 0A0DE3452BB8455A00812E73 /* MindboxNotificationServiceExtension.appex */; + productReference = 47F7FFDB2F87C02A0058A954 /* MindboxNotificationServiceExtension.appex */; productType = "com.apple.product-type.app-extension"; }; 0AD271CD2BB9D81D00750279 /* MindboxNotificationContentExtension */ = { @@ -306,9 +220,15 @@ ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + 47F7FC4A2F87B99E0058A954 /* MindboxNotificationContentExtension */, + ); name = MindboxNotificationContentExtension; + packageProductDependencies = ( + 47F703BF2F87C5BA0058A954 /* MindboxNotificationsContent */, + ); productName = MindboxNotificationContentExtension; - productReference = 0AD271CE2BB9D81D00750279 /* MindboxNotificationContentExtension.appex */; + productReference = 47F7FFDC2F87C02A0058A954 /* MindboxNotificationContentExtension.appex */; productType = "com.apple.product-type.app-extension"; }; 0AEDBC752BB6F8F200EE8722 /* Example */ = { @@ -326,9 +246,15 @@ 0A0DE34B2BB8455A00812E73 /* PBXTargetDependency */, 0AD271DB2BB9D81E00750279 /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + 47F7FC2B2F87B94A0058A954 /* Example */, + ); name = Example; + packageProductDependencies = ( + 47F703BD2F87C5BA0058A954 /* Mindbox */, + ); productName = Example; - productReference = 0AEDBC762BB6F8F200EE8722 /* Example.app */; + productReference = 47F7FFDA2F87C02A0058A954 /* Example.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -354,7 +280,6 @@ }; }; buildConfigurationList = 0AEDBC712BB6F8F200EE8722 /* Build configuration list for PBXProject "Example" */; - compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -362,9 +287,11 @@ Base, ); mainGroup = 0AEDBC6D2BB6F8F200EE8722; + minimizedProjectReferenceProxies = 1; packageReferences = ( - 47DB4FE62F868A6700A0AC53 /* XCRemoteSwiftPackageReference "ios-sdk" */, + 47F703BC2F87C5BA0058A954 /* XCRemoteSwiftPackageReference "ios-sdk" */, ); + preferredProjectObjectVersion = 77; productRefGroup = 0AEDBC772BB6F8F200EE8722 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -395,9 +322,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 0A4B682A2BBD7D5100639BC5 /* LaunchScreen.storyboard in Resources */, - 0AD271E42BBAAA8800750279 /* PrivacyInfo.xcprivacy in Resources */, - 0AEDBC832BB6F8F400EE8722 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -408,10 +332,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 47FFE2282C187D5C0007E2F6 /* SwiftDataManager.swift in Sources */, - 47FFE2232C187B650007E2F6 /* Item+SwiftData.swift in Sources */, - 0A0DE3482BB8455A00812E73 /* NotificationService.swift in Sources */, - 47FFE22C2C1886550007E2F6 /* Payload.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -419,7 +339,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 0AD271D52BB9D81E00750279 /* NotificationViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -427,19 +346,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 474F37A22C16F5A700F38BB0 /* NotificationCenterViewModel.swift in Sources */, - 0AEDBC7A2BB6F8F200EE8722 /* AppDelegate.swift in Sources */, - 0AEDBC992BB7058B00EE8722 /* ButtonsView.swift in Sources */, - 474F37A42C16F5B000F38BB0 /* NotificationCenterView.swift in Sources */, - 0AEDBC922BB6FA4800EE8722 /* MainView.swift in Sources */, - 472423992C185B8400B2A9BC /* Item+SwiftData.swift in Sources */, - 47FFE2262C187D3C0007E2F6 /* SwiftDataManager.swift in Sources */, - 474F37AE2C170FB600F38BB0 /* NotificationCellView.swift in Sources */, - 4774807A2C174BAA00580FB2 /* Payload.swift in Sources */, - 0AEDBC962BB6FE4900EE8722 /* MainViewModel.swift in Sources */, - 0AEDBC7C2BB6F8F200EE8722 /* SceneDelegate.swift in Sources */, - 0AEDBC9B2BB70D6E00EE8722 /* SDKDataView.swift in Sources */, - 0A4B681A2BBC82B500639BC5 /* ChooseInAppMessagesDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -466,11 +372,6 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 622436AMYX; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "\"${PODS_CONFIGURATION_BUILD_DIR}/MindboxLogger\"", - "\"${PODS_CONFIGURATION_BUILD_DIR}/MindboxNotifications\"", - ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = MindboxNotificationServiceExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = MindboxNotificationServiceExtension; @@ -498,11 +399,6 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 622436AMYX; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "\"${PODS_CONFIGURATION_BUILD_DIR}/MindboxLogger\"", - "\"${PODS_CONFIGURATION_BUILD_DIR}/MindboxNotifications\"", - ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = MindboxNotificationServiceExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = MindboxNotificationServiceExtension; @@ -699,7 +595,6 @@ 0AEDBC8B2BB6F8F400EE8722 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Example/Example.entitlements; @@ -732,7 +627,6 @@ 0AEDBC8C2BB6F8F400EE8722 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Example/Example.entitlements; @@ -804,7 +698,7 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 47DB4FE62F868A6700A0AC53 /* XCRemoteSwiftPackageReference "ios-sdk" */ = { + 47F703BC2F87C5BA0058A954 /* XCRemoteSwiftPackageReference "ios-sdk" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/mindbox-cloud/ios-sdk"; requirement = { @@ -815,19 +709,19 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 47DB4FE72F868A6700A0AC53 /* Mindbox */ = { + 47F703BD2F87C5BA0058A954 /* Mindbox */ = { isa = XCSwiftPackageProductDependency; - package = 47DB4FE62F868A6700A0AC53 /* XCRemoteSwiftPackageReference "ios-sdk" */; + package = 47F703BC2F87C5BA0058A954 /* XCRemoteSwiftPackageReference "ios-sdk" */; productName = Mindbox; }; - 47DB4FE92F868A6700A0AC53 /* MindboxNotificationsContent */ = { + 47F703BF2F87C5BA0058A954 /* MindboxNotificationsContent */ = { isa = XCSwiftPackageProductDependency; - package = 47DB4FE62F868A6700A0AC53 /* XCRemoteSwiftPackageReference "ios-sdk" */; + package = 47F703BC2F87C5BA0058A954 /* XCRemoteSwiftPackageReference "ios-sdk" */; productName = MindboxNotificationsContent; }; - 47DB4FEB2F868A6700A0AC53 /* MindboxNotificationsService */ = { + 47F703C12F87C5BA0058A954 /* MindboxNotificationsService */ = { isa = XCSwiftPackageProductDependency; - package = 47DB4FE62F868A6700A0AC53 /* XCRemoteSwiftPackageReference "ios-sdk" */; + package = 47F703BC2F87C5BA0058A954 /* XCRemoteSwiftPackageReference "ios-sdk" */; productName = MindboxNotificationsService; }; /* End XCSwiftPackageProductDependency section */ diff --git a/Example/Example/AppDelegate.swift b/Example/Example/App/AppDelegate.swift similarity index 100% rename from Example/Example/AppDelegate.swift rename to Example/Example/App/AppDelegate.swift diff --git a/Example/Example/Views/MainView.swift b/Example/Example/App/MainView.swift similarity index 100% rename from Example/Example/Views/MainView.swift rename to Example/Example/App/MainView.swift diff --git a/Example/Example/ViewModels/MainViewModel.swift b/Example/Example/App/MainViewModel.swift similarity index 100% rename from Example/Example/ViewModels/MainViewModel.swift rename to Example/Example/App/MainViewModel.swift diff --git a/Example/Example/Views/CustomViews/SDKDataView.swift b/Example/Example/App/SDKDataView.swift similarity index 100% rename from Example/Example/Views/CustomViews/SDKDataView.swift rename to Example/Example/App/SDKDataView.swift diff --git a/Example/Example/SceneDelegate.swift b/Example/Example/App/SceneDelegate.swift similarity index 100% rename from Example/Example/SceneDelegate.swift rename to Example/Example/App/SceneDelegate.swift diff --git a/Example/Example/Views/CustomViews/ButtonsView.swift b/Example/Example/InAppMessages/ButtonsView.swift similarity index 100% rename from Example/Example/Views/CustomViews/ButtonsView.swift rename to Example/Example/InAppMessages/ButtonsView.swift diff --git a/Example/Example/ViewModels/ChooseInAppMessagesDelegate/ChooseInAppMessagesDelegate.swift b/Example/Example/InAppMessages/ChooseInAppMessagesDelegate.swift similarity index 100% rename from Example/Example/ViewModels/ChooseInAppMessagesDelegate/ChooseInAppMessagesDelegate.swift rename to Example/Example/InAppMessages/ChooseInAppMessagesDelegate.swift diff --git a/Example/Example/Models/Payload.swift b/Example/Example/Models/Payload.swift deleted file mode 100644 index 717e1b104..000000000 --- a/Example/Example/Models/Payload.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// Payload.swift -// Example -// -// Created by Sergei Semko on 6/10/24. -// Copyright © 2024 Mindbox. All rights reserved. -// - -import Foundation - -public struct Payload: Codable { - var pushName: String - var pushDate: String -} diff --git a/Example/Example/Views/NotificationCenterViews/NotificationCenterView.swift b/Example/Example/NotificationCenter/NotificationCenterView.swift similarity index 71% rename from Example/Example/Views/NotificationCenterViews/NotificationCenterView.swift rename to Example/Example/NotificationCenter/NotificationCenterView.swift index 8f9fad849..971778cde 100644 --- a/Example/Example/Views/NotificationCenterViews/NotificationCenterView.swift +++ b/Example/Example/NotificationCenter/NotificationCenterView.swift @@ -8,18 +8,19 @@ import SwiftUI import SwiftData +import Mindbox struct NotificationCenterView: View { - + var viewModel: NotificationCenterViewModelProtocol - + @State private var showAlert = false @State private var alertTitle = String() @State private var alertMessage = String() - + @Environment(\.modelContext) private var modelContext @Query private var items: [Item] - + var body: some View { NavigationStack { List { @@ -31,7 +32,7 @@ struct NotificationCenterView: View { Spacer() } } - + .contentShape(Rectangle()) .onTapGesture { viewModel.sendOperationNCPushOpen(notification: item.mbPushNotification) @@ -60,7 +61,7 @@ struct NotificationCenterView: View { Button("OK", action: {}) } } - + private func deleteItems(offsets: IndexSet) { withAnimation { let originalOffsets = IndexSet(offsets.map { items.count - 1 - $0 }) @@ -70,7 +71,7 @@ struct NotificationCenterView: View { return } else { modelContext.delete(items[index]) - + } } } @@ -78,6 +79,39 @@ struct NotificationCenterView: View { } } +// MARK: - NotificationCellView + +private struct NotificationCellView: View { + var notification: PushNotification + + var body: some View { + HStack(alignment: .center, content: { + if let imageUrl = notification.imageUrl, let url = URL(string: imageUrl) { + AsyncImage(url: url) { image in + image + .resizable() + .scaledToFill() + } placeholder: { + ProgressView() + } + .frame(maxWidth: 64, maxHeight: 64) + .clipShape(Circle()) + } + + VStack(alignment: .leading, content: { + Text(notification.title ?? "Empty") + .font(.headline) + Text(notification.body ?? "Empty") + .font(.subheadline) + .foregroundStyle(.gray) + Text(notification.clickUrl ?? "Empty") + .font(.footnote) + .foregroundStyle(.blue) + }) + }) + } +} + #Preview { NotificationCenterView(viewModel: NotificationCenterViewModel()) } diff --git a/Example/Example/ViewModels/NotificationCenterViewModel.swift b/Example/Example/NotificationCenter/NotificationCenterViewModel.swift similarity index 100% rename from Example/Example/ViewModels/NotificationCenterViewModel.swift rename to Example/Example/NotificationCenter/NotificationCenterViewModel.swift diff --git a/Example/Example/Models/Item+SwiftData.swift b/Example/Example/NotificationCenter/NotificationModels.swift similarity index 88% rename from Example/Example/Models/Item+SwiftData.swift rename to Example/Example/NotificationCenter/NotificationModels.swift index 207c46748..d307a0168 100644 --- a/Example/Example/Models/Item+SwiftData.swift +++ b/Example/Example/NotificationCenter/NotificationModels.swift @@ -1,5 +1,5 @@ // -// Item+SwiftData.swift +// NotificationModels.swift // Example // // Created by Sergei Semko on 6/11/24. @@ -13,7 +13,7 @@ import SwiftData public final class Item { public var timestamp: Date public var mbPushNotification: PushNotification - + public init(timestamp: Date, pushNotification: PushNotification) { self.timestamp = timestamp self.mbPushNotification = pushNotification @@ -27,9 +27,14 @@ public struct PushNotification: Codable { public let imageUrl: String? public let payload: String? public let uniqueKey: String? - + var decodedPayload: Payload? { guard let payloadData = payload?.data(using: .utf8) else { return nil } return try? JSONDecoder().decode(Payload.self, from: payloadData) } } + +public struct Payload: Codable { + var pushName: String + var pushDate: String +} diff --git a/Example/Example/Services/SwiftDataManager.swift b/Example/Example/NotificationCenter/SwiftDataManager.swift similarity index 100% rename from Example/Example/Services/SwiftDataManager.swift rename to Example/Example/NotificationCenter/SwiftDataManager.swift diff --git a/Example/Example/Views/NotificationCenterViews/NotificationCellView.swift b/Example/Example/Views/NotificationCenterViews/NotificationCellView.swift deleted file mode 100644 index cd477a7da..000000000 --- a/Example/Example/Views/NotificationCenterViews/NotificationCellView.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// NotificationCellView.swift -// Example -// -// Created by Sergei Semko on 6/10/24. -// Copyright © 2024 Mindbox. All rights reserved. -// - -import SwiftUI -import Mindbox - -struct NotificationCellView: View { - var notification: PushNotification - - var body: some View { - HStack(alignment: .center, content: { - if let imageUrl = notification.imageUrl, let url = URL(string: imageUrl) { - AsyncImage(url: url) { image in - image - .resizable() - .scaledToFill() - } placeholder: { - ProgressView() - } - .frame(maxWidth: 64, maxHeight: 64) - .clipShape(Circle()) - } - - VStack(alignment: .leading, content: { - Text(notification.title ?? "Empty") - .font(.headline) - Text(notification.body ?? "Empty") - .font(.subheadline) - .foregroundStyle(.gray) - Text(notification.clickUrl ?? "Empty") - .font(.footnote) - .foregroundStyle(.blue) - }) - }) - } -} From e3933c4b20405fcc8a611b02d501a8ab8c99ba87 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:00:54 +0300 Subject: [PATCH 05/24] MOBILE-0000: Bump fastlane to 2.233.0 to fix scan crash on Xcode 26.4 (#702) Xcode 26.4 changed `xcrun simctl runtime list` output in a way that fastlane 2.232.2 mis-parses. scan crashes with `undefined method '[]' for nil` / `xcrun simctl runtime broken`, even though the simctl command returns valid JSON outside fastlane. Reproduced locally on the same Xcode that works for `xcodebuild test` directly. 2.233.0 includes the relevant fixes: - scan: Fix simulator selection when runtime build is missing from installed runtimes (#29894) - scan: Fix default_os_version when xcodebuild sdkVersion differs from simctl sdkVersion (#29977) Includes the auto-regenerated FastlaneSwiftRunner sources required by the new gem version. --- Gemfile.lock | 28 +- fastlane/swift/Actions.swift | 2 +- fastlane/swift/ArgumentProcessor.swift | 2 +- fastlane/swift/ControlCommand.swift | 9 +- fastlane/swift/DeliverfileProtocol.swift | 361 ++++++++++++--- fastlane/swift/Fastlane.swift | 340 ++++++++++---- fastlane/swift/GymfileProtocol.swift | 288 +++++++++--- fastlane/swift/LaneFileProtocol.swift | 6 +- fastlane/swift/MatchfileProtocol.swift | 290 +++++++++--- fastlane/swift/Plugins.swift | 2 +- fastlane/swift/PrecheckfileProtocol.swift | 58 ++- fastlane/swift/RubyCommand.swift | 13 +- fastlane/swift/RubyCommandable.swift | 2 +- fastlane/swift/Runner.swift | 4 +- fastlane/swift/RunnerArgument.swift | 2 +- fastlane/swift/ScanfileProtocol.swift | 414 ++++++++++++++---- fastlane/swift/ScreengrabfileProtocol.swift | 113 ++++- fastlane/swift/SnapshotfileProtocol.swift | 269 +++++++++--- fastlane/swift/SocketClient.swift | 4 +- .../swift/SocketClientDelegateProtocol.swift | 2 +- fastlane/swift/SocketResponse.swift | 2 +- fastlane/swift/main.swift | 2 +- 22 files changed, 1725 insertions(+), 488 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 9f2a0f600..50798be9e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -23,8 +23,8 @@ GEM artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1233.0) - aws-sdk-core (3.244.0) + aws-partitions (1.1241.0) + aws-sdk-core (3.246.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -35,7 +35,7 @@ GEM aws-sdk-kms (1.123.0) aws-sdk-core (~> 3, >= 3.244.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.218.0) + aws-sdk-s3 (1.220.0) aws-sdk-core (~> 3, >= 3.244.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) @@ -44,7 +44,7 @@ GEM babosa (1.0.4) base64 (0.2.0) benchmark (0.5.0) - bigdecimal (4.1.0) + bigdecimal (4.1.2) claide (1.1.0) cocoapods (1.16.2) addressable (~> 2.8) @@ -131,7 +131,7 @@ GEM faraday_middleware (1.2.1) faraday (~> 1.0) fastimage (2.4.1) - fastlane (2.232.2) + fastlane (2.233.0) CFPropertyList (>= 2.3, < 4.0.0) abbrev (~> 0.1.2) addressable (>= 2.8, < 3.0.0) @@ -151,7 +151,7 @@ GEM faraday-cookie_jar (~> 0.0.6) faraday_middleware (~> 1.0) fastimage (>= 2.1.0, < 3.0.0) - fastlane-sirp (>= 1.0.0) + fastlane-sirp (>= 1.1.0) gh_inspector (>= 1.1.2, < 2.0.0) google-apis-androidpublisher_v3 (~> 0.3) google-apis-playcustomapp_v1 (~> 0.1) @@ -181,8 +181,7 @@ GEM xcodeproj (>= 1.13.0, < 2.0.0) xcpretty (~> 0.4.1) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) - fastlane-sirp (1.0.0) - sysrandom (~> 1.0) + fastlane-sirp (1.1.0) ffi (1.17.4) ffi (1.17.4-aarch64-linux-gnu) ffi (1.17.4-aarch64-linux-musl) @@ -197,7 +196,7 @@ GEM fourflusher (2.3.1) fuzzy_match (2.0.4) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.98.0) + google-apis-androidpublisher_v3 (0.99.0) google-apis-core (>= 0.15.0, < 2.a) google-apis-core (0.18.0) addressable (~> 2.5, >= 2.5.1) @@ -207,11 +206,11 @@ GEM mutex_m representable (~> 3.0) retriable (>= 2.0, < 4.a) - google-apis-iamcredentials_v1 (0.26.0) + google-apis-iamcredentials_v1 (0.27.0) google-apis-core (>= 0.15.0, < 2.a) google-apis-playcustomapp_v1 (0.17.0) google-apis-core (>= 0.15.0, < 2.a) - google-apis-storage_v1 (0.61.0) + google-apis-storage_v1 (0.62.0) google-apis-core (>= 0.15.0, < 2.a) google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) @@ -243,7 +242,7 @@ GEM i18n (1.14.8) concurrent-ruby (~> 1.0) jmespath (1.6.2) - json (2.19.3) + json (2.19.4) jwt (2.10.2) base64 logger (1.7.0) @@ -251,7 +250,7 @@ GEM mini_mime (1.1.5) minitest (5.27.0) molinillo (0.8.0) - multi_json (1.19.1) + multi_json (1.20.1) multipart-post (2.4.1) mutex_m (0.3.0) nanaimo (0.4.0) @@ -264,7 +263,7 @@ GEM ostruct (0.6.3) plist (3.7.2) public_suffix (4.0.7) - rake (13.3.1) + rake (13.4.2) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) @@ -285,7 +284,6 @@ GEM simctl (1.6.10) CFPropertyList naturally - sysrandom (1.0.5) terminal-notifier (2.0.0) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) diff --git a/fastlane/swift/Actions.swift b/fastlane/swift/Actions.swift index 25157c7d3..f47e92f6a 100644 --- a/fastlane/swift/Actions.swift +++ b/fastlane/swift/Actions.swift @@ -1,5 +1,5 @@ // Actions.swift -// Copyright (c) 2025 FastlaneTools +// Copyright (c) 2026 FastlaneTools // This autogenerated file will be overwritten or replaced when running "fastlane generate_swift" // diff --git a/fastlane/swift/ArgumentProcessor.swift b/fastlane/swift/ArgumentProcessor.swift index 46513642a..01681e8ce 100644 --- a/fastlane/swift/ArgumentProcessor.swift +++ b/fastlane/swift/ArgumentProcessor.swift @@ -1,5 +1,5 @@ // ArgumentProcessor.swift -// Copyright (c) 2025 FastlaneTools +// Copyright (c) 2026 FastlaneTools // // ** NOTE ** diff --git a/fastlane/swift/ControlCommand.swift b/fastlane/swift/ControlCommand.swift index 4bdfea8e8..e9fc88169 100644 --- a/fastlane/swift/ControlCommand.swift +++ b/fastlane/swift/ControlCommand.swift @@ -1,5 +1,5 @@ // ControlCommand.swift -// Copyright (c) 2025 FastlaneTools +// Copyright (c) 2026 FastlaneTools // // ** NOTE ** @@ -12,7 +12,9 @@ import Foundation struct ControlCommand: RubyCommandable { static let commandKey = "command" - var type: CommandType { return .control } + var type: CommandType { + return .control + } enum ShutdownCommandType { static let userMessageKey: String = "userMessage" @@ -59,8 +61,7 @@ struct ControlCommand: RubyCommandable { } let jsonData = try! JSONSerialization.data(withJSONObject: jsonDictionary, options: []) - let jsonString = String(data: jsonData, encoding: .utf8)! - return jsonString + return String(data: jsonData, encoding: .utf8)! } init(commandType: ShutdownCommandType, message: String? = nil) { diff --git a/fastlane/swift/DeliverfileProtocol.swift b/fastlane/swift/DeliverfileProtocol.swift index f40a91849..ed5102a85 100644 --- a/fastlane/swift/DeliverfileProtocol.swift +++ b/fastlane/swift/DeliverfileProtocol.swift @@ -1,5 +1,5 @@ // DeliverfileProtocol.swift -// Copyright (c) 2025 FastlaneTools +// Copyright (c) 2026 FastlaneTools public protocol DeliverfileProtocol: AnyObject { /// Path to your App Store Connect API Key JSON file (https://docs.fastlane.tools/app-store-connect-api/#using-fastlane-api-key-json-file) @@ -41,6 +41,15 @@ public protocol DeliverfileProtocol: AnyObject { /// Path to the folder containing the screenshots var screenshotsPath: String? { get } + /// Path to the folder containing localized App Preview videos + var appPreviewsPath: String? { get } + + /// Time code for the App Preview still frame written as hour:minute:second:centisecond (e.g. 00:00:00:01) + var previewFrameTimeCode: String { get } + + /// Clear all previously uploaded App Preview videos before uploading the new ones + var overwritePreviewVideos: Bool { get } + /// Skip uploading an ipa or pkg to App Store Connect var skipBinaryUpload: Bool { get } @@ -113,6 +122,9 @@ public protocol DeliverfileProtocol: AnyObject { /// The provider short name to be used with the iTMSTransporter to identify your team. This value will override the automatically detected provider short name. To get provider short name run `pathToXcode.app/Contents/Applications/Application\ Loader.app/Contents/itms/bin/iTMSTransporter -m provider -u 'USERNAME' -p 'PASSWORD' -account_type itunes_connect -v off`. The short names of providers should be listed in the second column var itcProvider: String? { get } + /// The provider public ID to be used with altool (--provider-public-id). This value will override the automatically detected provider value for altool uploads. Required after Xcode 26 when your account is associated with multiple providers and using username/app-password authentication + var providerPublicId: String? { get } + /// Run precheck before submitting to app review var runPrecheckBeforeSubmit: Bool { get } @@ -202,74 +214,287 @@ public protocol DeliverfileProtocol: AnyObject { } public extension DeliverfileProtocol { - var apiKeyPath: String? { return nil } - var apiKey: [String: Any]? { return nil } - var username: String? { return nil } - var appIdentifier: String? { return nil } - var appVersion: String? { return nil } - var ipa: String? { return nil } - var pkg: String? { return nil } - var buildNumber: String? { return nil } - var platform: String { return "ios" } - var editLive: Bool { return false } - var useLiveVersion: Bool { return false } - var metadataPath: String? { return nil } - var screenshotsPath: String? { return nil } - var skipBinaryUpload: Bool { return false } - var skipScreenshots: Bool { return false } - var skipMetadata: Bool { return false } - var skipAppVersionUpdate: Bool { return false } - var force: Bool { return false } - var overwriteScreenshots: Bool { return false } - var screenshotProcessingTimeout: Int { return 3600 } - var syncScreenshots: Bool { return false } - var submitForReview: Bool { return false } - var verifyOnly: Bool { return false } - var rejectIfPossible: Bool { return false } - var versionCheckWaitRetryLimit: Int { return 7 } - var automaticRelease: Bool? { return nil } - var autoReleaseDate: Int? { return nil } - var phasedRelease: Bool { return false } - var resetRatings: Bool { return false } - var priceTier: Int? { return nil } - var appRatingConfigPath: String? { return nil } - var submissionInformation: [String: Any]? { return nil } - var teamId: String? { return nil } - var teamName: String? { return nil } - var devPortalTeamId: String? { return nil } - var devPortalTeamName: String? { return nil } - var itcProvider: String? { return nil } - var runPrecheckBeforeSubmit: Bool { return true } - var precheckDefaultRuleLevel: String { return "warn" } - var individualMetadataItems: [String]? { return nil } - var appIcon: String? { return nil } - var appleWatchAppIcon: String? { return nil } - var copyright: String? { return nil } - var primaryCategory: String? { return nil } - var secondaryCategory: String? { return nil } - var primaryFirstSubCategory: String? { return nil } - var primarySecondSubCategory: String? { return nil } - var secondaryFirstSubCategory: String? { return nil } - var secondarySecondSubCategory: String? { return nil } - var tradeRepresentativeContactInformation: [String: Any]? { return nil } - var appReviewInformation: [String: Any]? { return nil } - var appReviewAttachmentFile: String? { return nil } - var description: [String: Any]? { return nil } - var name: [String: Any]? { return nil } - var subtitle: [String: Any]? { return nil } - var keywords: [String: Any]? { return nil } - var promotionalText: [String: Any]? { return nil } - var releaseNotes: [String: Any]? { return nil } - var privacyUrl: [String: Any]? { return nil } - var appleTvPrivacyPolicy: [String: Any]? { return nil } - var supportUrl: [String: Any]? { return nil } - var marketingUrl: [String: Any]? { return nil } - var languages: [String]? { return nil } - var ignoreLanguageDirectoryValidation: Bool { return false } - var precheckIncludeInAppPurchases: Bool { return true } - var app: Int? { return nil } + var apiKeyPath: String? { + return nil + } + + var apiKey: [String: Any]? { + return nil + } + + var username: String? { + return nil + } + + var appIdentifier: String? { + return nil + } + + var appVersion: String? { + return nil + } + + var ipa: String? { + return nil + } + + var pkg: String? { + return nil + } + + var buildNumber: String? { + return nil + } + + var platform: String { + return "ios" + } + + var editLive: Bool { + return false + } + + var useLiveVersion: Bool { + return false + } + + var metadataPath: String? { + return nil + } + + var screenshotsPath: String? { + return nil + } + + var appPreviewsPath: String? { + return nil + } + + var previewFrameTimeCode: String { + return "00:00:05:00" + } + + var overwritePreviewVideos: Bool { + return false + } + + var skipBinaryUpload: Bool { + return false + } + + var skipScreenshots: Bool { + return false + } + + var skipMetadata: Bool { + return false + } + + var skipAppVersionUpdate: Bool { + return false + } + + var force: Bool { + return false + } + + var overwriteScreenshots: Bool { + return false + } + + var screenshotProcessingTimeout: Int { + return 3600 + } + + var syncScreenshots: Bool { + return false + } + + var submitForReview: Bool { + return false + } + + var verifyOnly: Bool { + return false + } + + var rejectIfPossible: Bool { + return false + } + + var versionCheckWaitRetryLimit: Int { + return 7 + } + + var automaticRelease: Bool? { + return nil + } + + var autoReleaseDate: Int? { + return nil + } + + var phasedRelease: Bool { + return false + } + + var resetRatings: Bool { + return false + } + + var priceTier: Int? { + return nil + } + + var appRatingConfigPath: String? { + return nil + } + + var submissionInformation: [String: Any]? { + return nil + } + + var teamId: String? { + return nil + } + + var teamName: String? { + return nil + } + + var devPortalTeamId: String? { + return nil + } + + var devPortalTeamName: String? { + return nil + } + + var itcProvider: String? { + return nil + } + + var providerPublicId: String? { + return nil + } + + var runPrecheckBeforeSubmit: Bool { + return true + } + + var precheckDefaultRuleLevel: String { + return "warn" + } + + var individualMetadataItems: [String]? { + return nil + } + + var appIcon: String? { + return nil + } + + var appleWatchAppIcon: String? { + return nil + } + + var copyright: String? { + return nil + } + + var primaryCategory: String? { + return nil + } + + var secondaryCategory: String? { + return nil + } + + var primaryFirstSubCategory: String? { + return nil + } + + var primarySecondSubCategory: String? { + return nil + } + + var secondaryFirstSubCategory: String? { + return nil + } + + var secondarySecondSubCategory: String? { + return nil + } + + var tradeRepresentativeContactInformation: [String: Any]? { + return nil + } + + var appReviewInformation: [String: Any]? { + return nil + } + + var appReviewAttachmentFile: String? { + return nil + } + + var description: [String: Any]? { + return nil + } + + var name: [String: Any]? { + return nil + } + + var subtitle: [String: Any]? { + return nil + } + + var keywords: [String: Any]? { + return nil + } + + var promotionalText: [String: Any]? { + return nil + } + + var releaseNotes: [String: Any]? { + return nil + } + + var privacyUrl: [String: Any]? { + return nil + } + + var appleTvPrivacyPolicy: [String: Any]? { + return nil + } + + var supportUrl: [String: Any]? { + return nil + } + + var marketingUrl: [String: Any]? { + return nil + } + + var languages: [String]? { + return nil + } + + var ignoreLanguageDirectoryValidation: Bool { + return false + } + + var precheckIncludeInAppPurchases: Bool { + return true + } + + var app: Int? { + return nil + } } // Please don't remove the lines below // They are used to detect outdated files -// FastlaneRunnerAPIVersion [0.9.138] +// FastlaneRunnerAPIVersion [0.9.145] diff --git a/fastlane/swift/Fastlane.swift b/fastlane/swift/Fastlane.swift index e421b9272..f157a4958 100644 --- a/fastlane/swift/Fastlane.swift +++ b/fastlane/swift/Fastlane.swift @@ -1,5 +1,5 @@ // Fastlane.swift -// Copyright (c) 2025 FastlaneTools +// Copyright (c) 2026 FastlaneTools import Foundation @@ -135,7 +135,7 @@ public func addGitTag(tag: OptionalConfigValue = .fastlaneDefault(nil), - parameters: - apiKeyPath: Path to your App Store Connect API Key JSON file (https://docs.fastlane.tools/app-store-connect-api/#using-fastlane-api-key-json-file) - apiKey: Your App Store Connect API Key information (https://docs.fastlane.tools/app-store-connect-api/#using-fastlane-api-key-hash-option) - - initialBuildNumber: sets the build number to given value if no build is in current train + - initialBuildNumber: sets the build number to given value if no build (upload) is in current train - appIdentifier: The bundle identifier of your app - username: Your Apple ID Username - teamId: The ID of your App Store Connect team if you're in multiple teams @@ -190,7 +190,7 @@ public func appStoreBuildNumber(apiKeyPath: OptionalConfigValue = .fast - parameters: - keyId: The key ID - - issuerId: The issuer ID. It can be nil if the key is individual API key + - issuerId: The issuer ID. It should be nil if the key is individual API key - keyFilepath: The path to the key p8 file - keyContent: The content of the key p8 file - isKeyContentBase64: Whether :key_content is Base64 encoded or not @@ -656,6 +656,9 @@ public func appledoc(input: [String], - useLiveVersion: Force usage of live version rather than edit version - metadataPath: Path to the folder containing the metadata files - screenshotsPath: Path to the folder containing the screenshots + - appPreviewsPath: Path to the folder containing localized App Preview videos + - previewFrameTimeCode: Time code for the App Preview still frame written as hour:minute:second:centisecond (e.g. 00:00:00:01) + - overwritePreviewVideos: Clear all previously uploaded App Preview videos before uploading the new ones - skipBinaryUpload: Skip uploading an ipa or pkg to App Store Connect - skipScreenshots: Don't upload the screenshots - skipMetadata: Don't upload the metadata (e.g. title, description). This will still upload screenshots @@ -680,6 +683,7 @@ public func appledoc(input: [String], - devPortalTeamId: The short ID of your Developer Portal team, if you're in multiple teams. Different from your iTC team ID! - devPortalTeamName: The name of your Developer Portal team if you're in multiple teams - itcProvider: The provider short name to be used with the iTMSTransporter to identify your team. This value will override the automatically detected provider short name. To get provider short name run `pathToXcode.app/Contents/Applications/Application\ Loader.app/Contents/itms/bin/iTMSTransporter -m provider -u 'USERNAME' -p 'PASSWORD' -account_type itunes_connect -v off`. The short names of providers should be listed in the second column + - providerPublicId: The provider public ID to be used with altool (--provider-public-id). This value will override the automatically detected provider value for altool uploads. Required after Xcode 26 when your account is associated with multiple providers and using username/app-password authentication - runPrecheckBeforeSubmit: Run precheck before submitting to app review - precheckDefaultRuleLevel: The default precheck rule level unless otherwise configured - individualMetadataItems: **DEPRECATED!** Removed after the migration to the new App Store Connect API in June 2020 - An array of localized metadata items to upload individually by language so that errors can be identified. E.g. ['name', 'keywords', 'description']. Note: slow @@ -715,7 +719,7 @@ public func appledoc(input: [String], If you don't want to verify an HTML preview for App Store builds, use the `:force` option. This is useful when running _fastlane_ on your Continuous Integration server: `_upload_to_app_store_(force: true)` - If your account is on multiple teams and you need to tell the `iTMSTransporter` which 'provider' to use, you can set the `:itc_provider` option to pass this info. + If your account is on multiple teams and you need to tell the transporter which provider to use, you can set `:itc_provider` or `:provider_public_id`. */ public func appstore(apiKeyPath: OptionalConfigValue = .fastlaneDefault(nil), apiKey: OptionalConfigValue<[String: Any]?> = .fastlaneDefault(nil), @@ -730,6 +734,9 @@ public func appstore(apiKeyPath: OptionalConfigValue = .fastlaneDefault useLiveVersion: OptionalConfigValue = .fastlaneDefault(false), metadataPath: OptionalConfigValue = .fastlaneDefault(nil), screenshotsPath: OptionalConfigValue = .fastlaneDefault(nil), + appPreviewsPath: OptionalConfigValue = .fastlaneDefault(nil), + previewFrameTimeCode: String = "00:00:05:00", + overwritePreviewVideos: OptionalConfigValue = .fastlaneDefault(false), skipBinaryUpload: OptionalConfigValue = .fastlaneDefault(false), skipScreenshots: OptionalConfigValue = .fastlaneDefault(false), skipMetadata: OptionalConfigValue = .fastlaneDefault(false), @@ -754,6 +761,7 @@ public func appstore(apiKeyPath: OptionalConfigValue = .fastlaneDefault devPortalTeamId: OptionalConfigValue = .fastlaneDefault(nil), devPortalTeamName: OptionalConfigValue = .fastlaneDefault(nil), itcProvider: OptionalConfigValue = .fastlaneDefault(nil), + providerPublicId: OptionalConfigValue = .fastlaneDefault(nil), runPrecheckBeforeSubmit: OptionalConfigValue = .fastlaneDefault(true), precheckDefaultRuleLevel: String = "warn", individualMetadataItems: OptionalConfigValue<[String]?> = .fastlaneDefault(nil), @@ -797,6 +805,9 @@ public func appstore(apiKeyPath: OptionalConfigValue = .fastlaneDefault let useLiveVersionArg = useLiveVersion.asRubyArgument(name: "use_live_version", type: nil) let metadataPathArg = metadataPath.asRubyArgument(name: "metadata_path", type: nil) let screenshotsPathArg = screenshotsPath.asRubyArgument(name: "screenshots_path", type: nil) + let appPreviewsPathArg = appPreviewsPath.asRubyArgument(name: "app_previews_path", type: nil) + let previewFrameTimeCodeArg = RubyCommand.Argument(name: "preview_frame_time_code", value: previewFrameTimeCode, type: nil) + let overwritePreviewVideosArg = overwritePreviewVideos.asRubyArgument(name: "overwrite_preview_videos", type: nil) let skipBinaryUploadArg = skipBinaryUpload.asRubyArgument(name: "skip_binary_upload", type: nil) let skipScreenshotsArg = skipScreenshots.asRubyArgument(name: "skip_screenshots", type: nil) let skipMetadataArg = skipMetadata.asRubyArgument(name: "skip_metadata", type: nil) @@ -821,6 +832,7 @@ public func appstore(apiKeyPath: OptionalConfigValue = .fastlaneDefault let devPortalTeamIdArg = devPortalTeamId.asRubyArgument(name: "dev_portal_team_id", type: nil) let devPortalTeamNameArg = devPortalTeamName.asRubyArgument(name: "dev_portal_team_name", type: nil) let itcProviderArg = itcProvider.asRubyArgument(name: "itc_provider", type: nil) + let providerPublicIdArg = providerPublicId.asRubyArgument(name: "provider_public_id", type: nil) let runPrecheckBeforeSubmitArg = runPrecheckBeforeSubmit.asRubyArgument(name: "run_precheck_before_submit", type: nil) let precheckDefaultRuleLevelArg = RubyCommand.Argument(name: "precheck_default_rule_level", value: precheckDefaultRuleLevel, type: nil) let individualMetadataItemsArg = individualMetadataItems.asRubyArgument(name: "individual_metadata_items", type: nil) @@ -863,6 +875,9 @@ public func appstore(apiKeyPath: OptionalConfigValue = .fastlaneDefault useLiveVersionArg, metadataPathArg, screenshotsPathArg, + appPreviewsPathArg, + previewFrameTimeCodeArg, + overwritePreviewVideosArg, skipBinaryUploadArg, skipScreenshotsArg, skipMetadataArg, @@ -887,6 +902,7 @@ public func appstore(apiKeyPath: OptionalConfigValue = .fastlaneDefault devPortalTeamIdArg, devPortalTeamNameArg, itcProviderArg, + providerPublicIdArg, runPrecheckBeforeSubmitArg, precheckDefaultRuleLevelArg, individualMetadataItemsArg, @@ -1298,6 +1314,7 @@ public func buildAndroidApp(task: OptionalConfigValue = .fastlaneDefaul - clean: Should the project be cleaned before building it? - outputDirectory: The directory in which the ipa file should be stored in - outputName: The name of the resulting ipa file + - appName: App name to use in logfile name - configuration: The configuration to use when building the app. Defaults to 'Release' - silent: Hide all information that's not necessary while building - codesigningIdentity: The name of the code signing identity to use. It has to match the name exactly. e.g. 'iPhone Distribution: SunApps GmbH' @@ -1339,10 +1356,13 @@ public func buildAndroidApp(task: OptionalConfigValue = .fastlaneDefaul - skipProfileDetection: Do not try to build a profile mapping from the xcodeproj. Match or a manually provided mapping should be used - xcodebuildCommand: Allows for override of the default `xcodebuild` command - clonedSourcePackagesPath: Sets a custom path for Swift Package Manager dependencies + - packageCachePath: Sets a custom package cache path for Swift Package Manager dependencies - skipPackageDependenciesResolution: Skips resolution of Swift Package Manager dependencies - - disablePackageAutomaticUpdates: Prevents packages from automatically being resolved to versions other than those recorded in the `Package.resolved` file + - disablePackageAutomaticUpdates: Prevents packages from automatically being resolved to versions other than those recorded in the `Package.resolved` file. This translates in the option `-disableAutomaticPackageResolution` being passed to xcodebuild + - skipPackageRepositoryFetches: Skips updating package dependencies from their remote. This translates in the option `-skipPackageUpdates` being passed to xcodebuild - useSystemScm: Lets xcodebuild use system's scm configuration - packageAuthorizationProvider: Lets xcodebuild use a specified package authorization provider (keychain|netrc) + - generateAppstoreInfo: Generate AppStoreInfo.plist using swinfo for app-store exports - returns: The absolute path to the generated ipa file @@ -1354,6 +1374,7 @@ public func buildAndroidApp(task: OptionalConfigValue = .fastlaneDefaul clean: OptionalConfigValue = .fastlaneDefault(false), outputDirectory: String = ".", outputName: OptionalConfigValue = .fastlaneDefault(nil), + appName: OptionalConfigValue = .fastlaneDefault(nil), configuration: OptionalConfigValue = .fastlaneDefault(nil), silent: OptionalConfigValue = .fastlaneDefault(false), codesigningIdentity: OptionalConfigValue = .fastlaneDefault(nil), @@ -1395,10 +1416,13 @@ public func buildAndroidApp(task: OptionalConfigValue = .fastlaneDefaul skipProfileDetection: OptionalConfigValue = .fastlaneDefault(false), xcodebuildCommand: String = "xcodebuild", clonedSourcePackagesPath: OptionalConfigValue = .fastlaneDefault(nil), + packageCachePath: OptionalConfigValue = .fastlaneDefault(nil), skipPackageDependenciesResolution: OptionalConfigValue = .fastlaneDefault(false), disablePackageAutomaticUpdates: OptionalConfigValue = .fastlaneDefault(false), + skipPackageRepositoryFetches: OptionalConfigValue = .fastlaneDefault(false), useSystemScm: OptionalConfigValue = .fastlaneDefault(false), - packageAuthorizationProvider: OptionalConfigValue = .fastlaneDefault(nil)) -> String + packageAuthorizationProvider: OptionalConfigValue = .fastlaneDefault(nil), + generateAppstoreInfo: OptionalConfigValue = .fastlaneDefault(false)) -> String { let workspaceArg = workspace.asRubyArgument(name: "workspace", type: nil) let projectArg = project.asRubyArgument(name: "project", type: nil) @@ -1406,6 +1430,7 @@ public func buildAndroidApp(task: OptionalConfigValue = .fastlaneDefaul let cleanArg = clean.asRubyArgument(name: "clean", type: nil) let outputDirectoryArg = RubyCommand.Argument(name: "output_directory", value: outputDirectory, type: nil) let outputNameArg = outputName.asRubyArgument(name: "output_name", type: nil) + let appNameArg = appName.asRubyArgument(name: "app_name", type: nil) let configurationArg = configuration.asRubyArgument(name: "configuration", type: nil) let silentArg = silent.asRubyArgument(name: "silent", type: nil) let codesigningIdentityArg = codesigningIdentity.asRubyArgument(name: "codesigning_identity", type: nil) @@ -1447,16 +1472,20 @@ public func buildAndroidApp(task: OptionalConfigValue = .fastlaneDefaul let skipProfileDetectionArg = skipProfileDetection.asRubyArgument(name: "skip_profile_detection", type: nil) let xcodebuildCommandArg = RubyCommand.Argument(name: "xcodebuild_command", value: xcodebuildCommand, type: nil) let clonedSourcePackagesPathArg = clonedSourcePackagesPath.asRubyArgument(name: "cloned_source_packages_path", type: nil) + let packageCachePathArg = packageCachePath.asRubyArgument(name: "package_cache_path", type: nil) let skipPackageDependenciesResolutionArg = skipPackageDependenciesResolution.asRubyArgument(name: "skip_package_dependencies_resolution", type: nil) let disablePackageAutomaticUpdatesArg = disablePackageAutomaticUpdates.asRubyArgument(name: "disable_package_automatic_updates", type: nil) + let skipPackageRepositoryFetchesArg = skipPackageRepositoryFetches.asRubyArgument(name: "skip_package_repository_fetches", type: nil) let useSystemScmArg = useSystemScm.asRubyArgument(name: "use_system_scm", type: nil) let packageAuthorizationProviderArg = packageAuthorizationProvider.asRubyArgument(name: "package_authorization_provider", type: nil) + let generateAppstoreInfoArg = generateAppstoreInfo.asRubyArgument(name: "generate_appstore_info", type: nil) let array: [RubyCommand.Argument?] = [workspaceArg, projectArg, schemeArg, cleanArg, outputDirectoryArg, outputNameArg, + appNameArg, configurationArg, silentArg, codesigningIdentityArg, @@ -1498,10 +1527,13 @@ public func buildAndroidApp(task: OptionalConfigValue = .fastlaneDefaul skipProfileDetectionArg, xcodebuildCommandArg, clonedSourcePackagesPathArg, + packageCachePathArg, skipPackageDependenciesResolutionArg, disablePackageAutomaticUpdatesArg, + skipPackageRepositoryFetchesArg, useSystemScmArg, - packageAuthorizationProviderArg] + packageAuthorizationProviderArg, + generateAppstoreInfoArg] let args: [RubyCommand.Argument] = array .filter { $0?.value != nil } .compactMap { $0 } @@ -1519,6 +1551,7 @@ public func buildAndroidApp(task: OptionalConfigValue = .fastlaneDefaul - clean: Should the project be cleaned before building it? - outputDirectory: The directory in which the ipa file should be stored in - outputName: The name of the resulting ipa file + - appName: App name to use in logfile name - configuration: The configuration to use when building the app. Defaults to 'Release' - silent: Hide all information that's not necessary while building - codesigningIdentity: The name of the code signing identity to use. It has to match the name exactly. e.g. 'iPhone Distribution: SunApps GmbH' @@ -1557,10 +1590,13 @@ public func buildAndroidApp(task: OptionalConfigValue = .fastlaneDefaul - skipProfileDetection: Do not try to build a profile mapping from the xcodeproj. Match or a manually provided mapping should be used - xcodebuildCommand: Allows for override of the default `xcodebuild` command - clonedSourcePackagesPath: Sets a custom path for Swift Package Manager dependencies + - packageCachePath: Sets a custom package cache path for Swift Package Manager dependencies - skipPackageDependenciesResolution: Skips resolution of Swift Package Manager dependencies - - disablePackageAutomaticUpdates: Prevents packages from automatically being resolved to versions other than those recorded in the `Package.resolved` file + - disablePackageAutomaticUpdates: Prevents packages from automatically being resolved to versions other than those recorded in the `Package.resolved` file. This translates in the option `-disableAutomaticPackageResolution` being passed to xcodebuild + - skipPackageRepositoryFetches: Skips updating package dependencies from their remote. This translates in the option `-skipPackageUpdates` being passed to xcodebuild - useSystemScm: Lets xcodebuild use system's scm configuration - packageAuthorizationProvider: Lets xcodebuild use a specified package authorization provider (keychain|netrc) + - generateAppstoreInfo: Generate AppStoreInfo.plist using swinfo for app-store exports - returns: The absolute path to the generated ipa file @@ -1572,6 +1608,7 @@ public func buildAndroidApp(task: OptionalConfigValue = .fastlaneDefaul clean: OptionalConfigValue = .fastlaneDefault(false), outputDirectory: String = ".", outputName: OptionalConfigValue = .fastlaneDefault(nil), + appName: OptionalConfigValue = .fastlaneDefault(nil), configuration: OptionalConfigValue = .fastlaneDefault(nil), silent: OptionalConfigValue = .fastlaneDefault(false), codesigningIdentity: OptionalConfigValue = .fastlaneDefault(nil), @@ -1610,10 +1647,13 @@ public func buildAndroidApp(task: OptionalConfigValue = .fastlaneDefaul skipProfileDetection: OptionalConfigValue = .fastlaneDefault(false), xcodebuildCommand: String = "xcodebuild", clonedSourcePackagesPath: OptionalConfigValue = .fastlaneDefault(nil), + packageCachePath: OptionalConfigValue = .fastlaneDefault(nil), skipPackageDependenciesResolution: OptionalConfigValue = .fastlaneDefault(false), disablePackageAutomaticUpdates: OptionalConfigValue = .fastlaneDefault(false), + skipPackageRepositoryFetches: OptionalConfigValue = .fastlaneDefault(false), useSystemScm: OptionalConfigValue = .fastlaneDefault(false), - packageAuthorizationProvider: OptionalConfigValue = .fastlaneDefault(nil)) -> String + packageAuthorizationProvider: OptionalConfigValue = .fastlaneDefault(nil), + generateAppstoreInfo: OptionalConfigValue = .fastlaneDefault(false)) -> String { let workspaceArg = workspace.asRubyArgument(name: "workspace", type: nil) let projectArg = project.asRubyArgument(name: "project", type: nil) @@ -1621,6 +1661,7 @@ public func buildAndroidApp(task: OptionalConfigValue = .fastlaneDefaul let cleanArg = clean.asRubyArgument(name: "clean", type: nil) let outputDirectoryArg = RubyCommand.Argument(name: "output_directory", value: outputDirectory, type: nil) let outputNameArg = outputName.asRubyArgument(name: "output_name", type: nil) + let appNameArg = appName.asRubyArgument(name: "app_name", type: nil) let configurationArg = configuration.asRubyArgument(name: "configuration", type: nil) let silentArg = silent.asRubyArgument(name: "silent", type: nil) let codesigningIdentityArg = codesigningIdentity.asRubyArgument(name: "codesigning_identity", type: nil) @@ -1659,16 +1700,20 @@ public func buildAndroidApp(task: OptionalConfigValue = .fastlaneDefaul let skipProfileDetectionArg = skipProfileDetection.asRubyArgument(name: "skip_profile_detection", type: nil) let xcodebuildCommandArg = RubyCommand.Argument(name: "xcodebuild_command", value: xcodebuildCommand, type: nil) let clonedSourcePackagesPathArg = clonedSourcePackagesPath.asRubyArgument(name: "cloned_source_packages_path", type: nil) + let packageCachePathArg = packageCachePath.asRubyArgument(name: "package_cache_path", type: nil) let skipPackageDependenciesResolutionArg = skipPackageDependenciesResolution.asRubyArgument(name: "skip_package_dependencies_resolution", type: nil) let disablePackageAutomaticUpdatesArg = disablePackageAutomaticUpdates.asRubyArgument(name: "disable_package_automatic_updates", type: nil) + let skipPackageRepositoryFetchesArg = skipPackageRepositoryFetches.asRubyArgument(name: "skip_package_repository_fetches", type: nil) let useSystemScmArg = useSystemScm.asRubyArgument(name: "use_system_scm", type: nil) let packageAuthorizationProviderArg = packageAuthorizationProvider.asRubyArgument(name: "package_authorization_provider", type: nil) + let generateAppstoreInfoArg = generateAppstoreInfo.asRubyArgument(name: "generate_appstore_info", type: nil) let array: [RubyCommand.Argument?] = [workspaceArg, projectArg, schemeArg, cleanArg, outputDirectoryArg, outputNameArg, + appNameArg, configurationArg, silentArg, codesigningIdentityArg, @@ -1707,10 +1752,13 @@ public func buildAndroidApp(task: OptionalConfigValue = .fastlaneDefaul skipProfileDetectionArg, xcodebuildCommandArg, clonedSourcePackagesPathArg, + packageCachePathArg, skipPackageDependenciesResolutionArg, disablePackageAutomaticUpdatesArg, + skipPackageRepositoryFetchesArg, useSystemScmArg, - packageAuthorizationProviderArg] + packageAuthorizationProviderArg, + generateAppstoreInfoArg] let args: [RubyCommand.Argument] = array .filter { $0?.value != nil } .compactMap { $0 } @@ -1728,6 +1776,7 @@ public func buildAndroidApp(task: OptionalConfigValue = .fastlaneDefaul - clean: Should the project be cleaned before building it? - outputDirectory: The directory in which the ipa file should be stored in - outputName: The name of the resulting ipa file + - appName: App name to use in logfile name - configuration: The configuration to use when building the app. Defaults to 'Release' - silent: Hide all information that's not necessary while building - codesigningIdentity: The name of the code signing identity to use. It has to match the name exactly. e.g. 'iPhone Distribution: SunApps GmbH' @@ -1767,10 +1816,13 @@ public func buildAndroidApp(task: OptionalConfigValue = .fastlaneDefaul - skipProfileDetection: Do not try to build a profile mapping from the xcodeproj. Match or a manually provided mapping should be used - xcodebuildCommand: Allows for override of the default `xcodebuild` command - clonedSourcePackagesPath: Sets a custom path for Swift Package Manager dependencies + - packageCachePath: Sets a custom package cache path for Swift Package Manager dependencies - skipPackageDependenciesResolution: Skips resolution of Swift Package Manager dependencies - - disablePackageAutomaticUpdates: Prevents packages from automatically being resolved to versions other than those recorded in the `Package.resolved` file + - disablePackageAutomaticUpdates: Prevents packages from automatically being resolved to versions other than those recorded in the `Package.resolved` file. This translates in the option `-disableAutomaticPackageResolution` being passed to xcodebuild + - skipPackageRepositoryFetches: Skips updating package dependencies from their remote. This translates in the option `-skipPackageUpdates` being passed to xcodebuild - useSystemScm: Lets xcodebuild use system's scm configuration - packageAuthorizationProvider: Lets xcodebuild use a specified package authorization provider (keychain|netrc) + - generateAppstoreInfo: Generate AppStoreInfo.plist using swinfo for app-store exports - returns: The absolute path to the generated ipa file @@ -1782,6 +1834,7 @@ public func buildAndroidApp(task: OptionalConfigValue = .fastlaneDefaul clean: OptionalConfigValue = .fastlaneDefault(false), outputDirectory: String = ".", outputName: OptionalConfigValue = .fastlaneDefault(nil), + appName: OptionalConfigValue = .fastlaneDefault(nil), configuration: OptionalConfigValue = .fastlaneDefault(nil), silent: OptionalConfigValue = .fastlaneDefault(false), codesigningIdentity: OptionalConfigValue = .fastlaneDefault(nil), @@ -1821,10 +1874,13 @@ public func buildAndroidApp(task: OptionalConfigValue = .fastlaneDefaul skipProfileDetection: OptionalConfigValue = .fastlaneDefault(false), xcodebuildCommand: String = "xcodebuild", clonedSourcePackagesPath: OptionalConfigValue = .fastlaneDefault(nil), + packageCachePath: OptionalConfigValue = .fastlaneDefault(nil), skipPackageDependenciesResolution: OptionalConfigValue = .fastlaneDefault(false), disablePackageAutomaticUpdates: OptionalConfigValue = .fastlaneDefault(false), + skipPackageRepositoryFetches: OptionalConfigValue = .fastlaneDefault(false), useSystemScm: OptionalConfigValue = .fastlaneDefault(false), - packageAuthorizationProvider: OptionalConfigValue = .fastlaneDefault(nil)) -> String + packageAuthorizationProvider: OptionalConfigValue = .fastlaneDefault(nil), + generateAppstoreInfo: OptionalConfigValue = .fastlaneDefault(false)) -> String { let workspaceArg = workspace.asRubyArgument(name: "workspace", type: nil) let projectArg = project.asRubyArgument(name: "project", type: nil) @@ -1832,6 +1888,7 @@ public func buildAndroidApp(task: OptionalConfigValue = .fastlaneDefaul let cleanArg = clean.asRubyArgument(name: "clean", type: nil) let outputDirectoryArg = RubyCommand.Argument(name: "output_directory", value: outputDirectory, type: nil) let outputNameArg = outputName.asRubyArgument(name: "output_name", type: nil) + let appNameArg = appName.asRubyArgument(name: "app_name", type: nil) let configurationArg = configuration.asRubyArgument(name: "configuration", type: nil) let silentArg = silent.asRubyArgument(name: "silent", type: nil) let codesigningIdentityArg = codesigningIdentity.asRubyArgument(name: "codesigning_identity", type: nil) @@ -1871,16 +1928,20 @@ public func buildAndroidApp(task: OptionalConfigValue = .fastlaneDefaul let skipProfileDetectionArg = skipProfileDetection.asRubyArgument(name: "skip_profile_detection", type: nil) let xcodebuildCommandArg = RubyCommand.Argument(name: "xcodebuild_command", value: xcodebuildCommand, type: nil) let clonedSourcePackagesPathArg = clonedSourcePackagesPath.asRubyArgument(name: "cloned_source_packages_path", type: nil) + let packageCachePathArg = packageCachePath.asRubyArgument(name: "package_cache_path", type: nil) let skipPackageDependenciesResolutionArg = skipPackageDependenciesResolution.asRubyArgument(name: "skip_package_dependencies_resolution", type: nil) let disablePackageAutomaticUpdatesArg = disablePackageAutomaticUpdates.asRubyArgument(name: "disable_package_automatic_updates", type: nil) + let skipPackageRepositoryFetchesArg = skipPackageRepositoryFetches.asRubyArgument(name: "skip_package_repository_fetches", type: nil) let useSystemScmArg = useSystemScm.asRubyArgument(name: "use_system_scm", type: nil) let packageAuthorizationProviderArg = packageAuthorizationProvider.asRubyArgument(name: "package_authorization_provider", type: nil) + let generateAppstoreInfoArg = generateAppstoreInfo.asRubyArgument(name: "generate_appstore_info", type: nil) let array: [RubyCommand.Argument?] = [workspaceArg, projectArg, schemeArg, cleanArg, outputDirectoryArg, outputNameArg, + appNameArg, configurationArg, silentArg, codesigningIdentityArg, @@ -1920,10 +1981,13 @@ public func buildAndroidApp(task: OptionalConfigValue = .fastlaneDefaul skipProfileDetectionArg, xcodebuildCommandArg, clonedSourcePackagesPathArg, + packageCachePathArg, skipPackageDependenciesResolutionArg, disablePackageAutomaticUpdatesArg, + skipPackageRepositoryFetchesArg, useSystemScmArg, - packageAuthorizationProviderArg] + packageAuthorizationProviderArg, + generateAppstoreInfoArg] let args: [RubyCommand.Argument] = array .filter { $0?.value != nil } .compactMap { $0 } @@ -2168,8 +2232,10 @@ public func captureAndroidScreenshots(androidHome: OptionalConfigValue - concurrentSimulators: Take snapshots on multiple simulators concurrently. Note: This option is only applicable when running against Xcode 9 - disableSlideToType: Disable the simulator from showing the 'Slide to type' prompt - clonedSourcePackagesPath: Sets a custom path for Swift Package Manager dependencies + - packageCachePath: Sets a custom package cache path for Swift Package Manager dependencies - skipPackageDependenciesResolution: Skips resolution of Swift Package Manager dependencies - - disablePackageAutomaticUpdates: Prevents packages from automatically being resolved to versions other than those recorded in the `Package.resolved` file + - disablePackageAutomaticUpdates: Prevents packages from automatically being resolved to versions other than those recorded in the `Package.resolved` file. This translates in the option `-disableAutomaticPackageResolution` being passed to xcodebuild + - skipPackageRepositoryFetches: Skips updating package dependencies from their remote. This translates in the option `-skipPackageUpdates` being passed to xcodebuild - packageAuthorizationProvider: Lets xcodebuild use a specified package authorization provider (keychain|netrc) - testplan: The testplan associated with the scheme that should be used for testing - onlyTesting: Array of strings matching Test Bundle/Test Suite/Test Cases to run @@ -2219,8 +2285,10 @@ public func captureIosScreenshots(workspace: OptionalConfigValue = .fas concurrentSimulators: OptionalConfigValue = .fastlaneDefault(true), disableSlideToType: OptionalConfigValue = .fastlaneDefault(false), clonedSourcePackagesPath: OptionalConfigValue = .fastlaneDefault(nil), + packageCachePath: OptionalConfigValue = .fastlaneDefault(nil), skipPackageDependenciesResolution: OptionalConfigValue = .fastlaneDefault(false), disablePackageAutomaticUpdates: OptionalConfigValue = .fastlaneDefault(false), + skipPackageRepositoryFetches: OptionalConfigValue = .fastlaneDefault(false), packageAuthorizationProvider: OptionalConfigValue = .fastlaneDefault(nil), testplan: OptionalConfigValue = .fastlaneDefault(nil), onlyTesting: Any? = nil, @@ -2270,8 +2338,10 @@ public func captureIosScreenshots(workspace: OptionalConfigValue = .fas let concurrentSimulatorsArg = concurrentSimulators.asRubyArgument(name: "concurrent_simulators", type: nil) let disableSlideToTypeArg = disableSlideToType.asRubyArgument(name: "disable_slide_to_type", type: nil) let clonedSourcePackagesPathArg = clonedSourcePackagesPath.asRubyArgument(name: "cloned_source_packages_path", type: nil) + let packageCachePathArg = packageCachePath.asRubyArgument(name: "package_cache_path", type: nil) let skipPackageDependenciesResolutionArg = skipPackageDependenciesResolution.asRubyArgument(name: "skip_package_dependencies_resolution", type: nil) let disablePackageAutomaticUpdatesArg = disablePackageAutomaticUpdates.asRubyArgument(name: "disable_package_automatic_updates", type: nil) + let skipPackageRepositoryFetchesArg = skipPackageRepositoryFetches.asRubyArgument(name: "skip_package_repository_fetches", type: nil) let packageAuthorizationProviderArg = packageAuthorizationProvider.asRubyArgument(name: "package_authorization_provider", type: nil) let testplanArg = testplan.asRubyArgument(name: "testplan", type: nil) let onlyTestingArg = RubyCommand.Argument(name: "only_testing", value: onlyTesting, type: nil) @@ -2320,8 +2390,10 @@ public func captureIosScreenshots(workspace: OptionalConfigValue = .fas concurrentSimulatorsArg, disableSlideToTypeArg, clonedSourcePackagesPathArg, + packageCachePathArg, skipPackageDependenciesResolutionArg, disablePackageAutomaticUpdatesArg, + skipPackageRepositoryFetchesArg, packageAuthorizationProviderArg, testplanArg, onlyTestingArg, @@ -2381,8 +2453,10 @@ public func captureIosScreenshots(workspace: OptionalConfigValue = .fas - concurrentSimulators: Take snapshots on multiple simulators concurrently. Note: This option is only applicable when running against Xcode 9 - disableSlideToType: Disable the simulator from showing the 'Slide to type' prompt - clonedSourcePackagesPath: Sets a custom path for Swift Package Manager dependencies + - packageCachePath: Sets a custom package cache path for Swift Package Manager dependencies - skipPackageDependenciesResolution: Skips resolution of Swift Package Manager dependencies - - disablePackageAutomaticUpdates: Prevents packages from automatically being resolved to versions other than those recorded in the `Package.resolved` file + - disablePackageAutomaticUpdates: Prevents packages from automatically being resolved to versions other than those recorded in the `Package.resolved` file. This translates in the option `-disableAutomaticPackageResolution` being passed to xcodebuild + - skipPackageRepositoryFetches: Skips updating package dependencies from their remote. This translates in the option `-skipPackageUpdates` being passed to xcodebuild - packageAuthorizationProvider: Lets xcodebuild use a specified package authorization provider (keychain|netrc) - testplan: The testplan associated with the scheme that should be used for testing - onlyTesting: Array of strings matching Test Bundle/Test Suite/Test Cases to run @@ -2432,8 +2506,10 @@ public func captureScreenshots(workspace: OptionalConfigValue = .fastla concurrentSimulators: OptionalConfigValue = .fastlaneDefault(true), disableSlideToType: OptionalConfigValue = .fastlaneDefault(false), clonedSourcePackagesPath: OptionalConfigValue = .fastlaneDefault(nil), + packageCachePath: OptionalConfigValue = .fastlaneDefault(nil), skipPackageDependenciesResolution: OptionalConfigValue = .fastlaneDefault(false), disablePackageAutomaticUpdates: OptionalConfigValue = .fastlaneDefault(false), + skipPackageRepositoryFetches: OptionalConfigValue = .fastlaneDefault(false), packageAuthorizationProvider: OptionalConfigValue = .fastlaneDefault(nil), testplan: OptionalConfigValue = .fastlaneDefault(nil), onlyTesting: Any? = nil, @@ -2483,8 +2559,10 @@ public func captureScreenshots(workspace: OptionalConfigValue = .fastla let concurrentSimulatorsArg = concurrentSimulators.asRubyArgument(name: "concurrent_simulators", type: nil) let disableSlideToTypeArg = disableSlideToType.asRubyArgument(name: "disable_slide_to_type", type: nil) let clonedSourcePackagesPathArg = clonedSourcePackagesPath.asRubyArgument(name: "cloned_source_packages_path", type: nil) + let packageCachePathArg = packageCachePath.asRubyArgument(name: "package_cache_path", type: nil) let skipPackageDependenciesResolutionArg = skipPackageDependenciesResolution.asRubyArgument(name: "skip_package_dependencies_resolution", type: nil) let disablePackageAutomaticUpdatesArg = disablePackageAutomaticUpdates.asRubyArgument(name: "disable_package_automatic_updates", type: nil) + let skipPackageRepositoryFetchesArg = skipPackageRepositoryFetches.asRubyArgument(name: "skip_package_repository_fetches", type: nil) let packageAuthorizationProviderArg = packageAuthorizationProvider.asRubyArgument(name: "package_authorization_provider", type: nil) let testplanArg = testplan.asRubyArgument(name: "testplan", type: nil) let onlyTestingArg = RubyCommand.Argument(name: "only_testing", value: onlyTesting, type: nil) @@ -2533,8 +2611,10 @@ public func captureScreenshots(workspace: OptionalConfigValue = .fastla concurrentSimulatorsArg, disableSlideToTypeArg, clonedSourcePackagesPathArg, + packageCachePathArg, skipPackageDependenciesResolutionArg, disablePackageAutomaticUpdatesArg, + skipPackageRepositoryFetchesArg, packageAuthorizationProviderArg, testplanArg, onlyTestingArg, @@ -3045,15 +3125,6 @@ public func cloc(binaryPath: String = "/usr/local/bin/cloc", _ = runner.executeCommand(command) } -/** - Print a Club Mate in your build output - */ -public func clubmate() { - let args: [RubyCommand.Argument] = [] - let command = RubyCommand(commandID: "", methodName: "clubmate", className: nil, args: args) - _ = runner.executeCommand(command) -} - /** Runs `pod install` for the project @@ -3320,7 +3391,7 @@ public func createAppOnManagedPlayStore(jsonKey: OptionalConfigValue = - skipItc: Skip the creation of the app on App Store Connect - itcUsers: Array of App Store Connect users. If provided, you can limit access to this newly created app for users with the App Manager, Developer, Marketer or Sales roles - enabledFeatures: **DEPRECATED!** Please use `enable_services` instead - Array with Spaceship App Services - - enableServices: Array with Spaceship App Services (e.g. access_wifi: (on|off), app_attest: (on|off), app_group: (on|off), apple_pay: (on|off), associated_domains: (on|off), auto_fill_credential: (on|off), class_kit: (on|off), icloud: (legacy|cloudkit), custom_network_protocol: (on|off), data_protection: (complete|unlessopen|untilfirstauth), extended_virtual_address_space: (on|off), family_controls: (on|off), file_provider_testing_mode: (on|off), fonts: (on|off), game_center: (ios|mac), health_kit: (on|off), hls_interstitial_preview: (on|off), home_kit: (on|off), hotspot: (on|off), in_app_purchase: (on|off), inter_app_audio: (on|off), low_latency_hls: (on|off), managed_associated_domains: (on|off), maps: (on|off), multipath: (on|off), network_extension: (on|off), nfc_tag_reading: (on|off), personal_vpn: (on|off), passbook: (on|off), push_notification: (on|off), sign_in_with_apple: (on), siri_kit: (on|off), system_extension: (on|off), user_management: (on|off), vpn_configuration: (on|off), wallet: (on|off), wireless_accessory: (on|off), car_play_audio_app: (on|off), car_play_messaging_app: (on|off), car_play_navigation_app: (on|off), car_play_voip_calling_app: (on|off), critical_alerts: (on|off), hotspot_helper: (on|off), driver_kit: (on|off), driver_kit_endpoint_security: (on|off), driver_kit_family_hid_device: (on|off), driver_kit_family_networking: (on|off), driver_kit_family_serial: (on|off), driver_kit_hid_event_service: (on|off), driver_kit_transport_hid: (on|off), multitasking_camera_access: (on|off), sf_universal_link_api: (on|off), vp9_decoder: (on|off), music_kit: (on|off), shazam_kit: (on|off), communication_notifications: (on|off), group_activities: (on|off), health_kit_estimate_recalibration: (on|off), time_sensitive_notifications: (on|off)) + - enableServices: Array with Spaceship App Services (e.g. access_wifi: (on|off), app_attest: (on|off), app_group: (on|off), apple_pay: (on|off), associated_domains: (on|off), auto_fill_credential: (on|off), class_kit: (on|off), declared_age_range: (on|off), icloud: (legacy|cloudkit), custom_network_protocol: (on|off), data_protection: (complete|unlessopen|untilfirstauth), extended_virtual_address_space: (on|off), family_controls: (on|off), file_provider_testing_mode: (on|off), fonts: (on|off), game_center: (ios|mac), health_kit: (on|off), hls_interstitial_preview: (on|off), home_kit: (on|off), hotspot: (on|off), in_app_purchase: (on|off), inter_app_audio: (on|off), low_latency_hls: (on|off), managed_associated_domains: (on|off), maps: (on|off), multipath: (on|off), network_extension: (on|off), nfc_tag_reading: (on|off), personal_vpn: (on|off), passbook: (on|off), push_notification: (on|off), sign_in_with_apple: (on), siri_kit: (on|off), system_extension: (on|off), user_management: (on|off), vpn_configuration: (on|off), wallet: (on|off), wireless_accessory: (on|off), car_play_audio_app: (on|off), car_play_messaging_app: (on|off), car_play_navigation_app: (on|off), car_play_voip_calling_app: (on|off), critical_alerts: (on|off), hotspot_helper: (on|off), driver_kit: (on|off), driver_kit_endpoint_security: (on|off), driver_kit_family_hid_device: (on|off), driver_kit_family_networking: (on|off), driver_kit_family_serial: (on|off), driver_kit_hid_event_service: (on|off), driver_kit_transport_hid: (on|off), multitasking_camera_access: (on|off), sf_universal_link_api: (on|off), vp9_decoder: (on|off), music_kit: (on|off), shazam_kit: (on|off), communication_notifications: (on|off), group_activities: (on|off), health_kit_estimate_recalibration: (on|off), time_sensitive_notifications: (on|off)) - skipDevcenter: Skip the creation of the app on the Apple Developer Portal - teamId: The ID of your Developer Portal team if you're in multiple teams - teamName: The name of your Developer Portal team if you're in multiple teams @@ -3720,6 +3791,9 @@ public func deleteKeychain(name: OptionalConfigValue = .fastlaneDefault - useLiveVersion: Force usage of live version rather than edit version - metadataPath: Path to the folder containing the metadata files - screenshotsPath: Path to the folder containing the screenshots + - appPreviewsPath: Path to the folder containing localized App Preview videos + - previewFrameTimeCode: Time code for the App Preview still frame written as hour:minute:second:centisecond (e.g. 00:00:00:01) + - overwritePreviewVideos: Clear all previously uploaded App Preview videos before uploading the new ones - skipBinaryUpload: Skip uploading an ipa or pkg to App Store Connect - skipScreenshots: Don't upload the screenshots - skipMetadata: Don't upload the metadata (e.g. title, description). This will still upload screenshots @@ -3744,6 +3818,7 @@ public func deleteKeychain(name: OptionalConfigValue = .fastlaneDefault - devPortalTeamId: The short ID of your Developer Portal team, if you're in multiple teams. Different from your iTC team ID! - devPortalTeamName: The name of your Developer Portal team if you're in multiple teams - itcProvider: The provider short name to be used with the iTMSTransporter to identify your team. This value will override the automatically detected provider short name. To get provider short name run `pathToXcode.app/Contents/Applications/Application\ Loader.app/Contents/itms/bin/iTMSTransporter -m provider -u 'USERNAME' -p 'PASSWORD' -account_type itunes_connect -v off`. The short names of providers should be listed in the second column + - providerPublicId: The provider public ID to be used with altool (--provider-public-id). This value will override the automatically detected provider value for altool uploads. Required after Xcode 26 when your account is associated with multiple providers and using username/app-password authentication - runPrecheckBeforeSubmit: Run precheck before submitting to app review - precheckDefaultRuleLevel: The default precheck rule level unless otherwise configured - individualMetadataItems: **DEPRECATED!** Removed after the migration to the new App Store Connect API in June 2020 - An array of localized metadata items to upload individually by language so that errors can be identified. E.g. ['name', 'keywords', 'description']. Note: slow @@ -3779,7 +3854,7 @@ public func deleteKeychain(name: OptionalConfigValue = .fastlaneDefault If you don't want to verify an HTML preview for App Store builds, use the `:force` option. This is useful when running _fastlane_ on your Continuous Integration server: `_upload_to_app_store_(force: true)` - If your account is on multiple teams and you need to tell the `iTMSTransporter` which 'provider' to use, you can set the `:itc_provider` option to pass this info. + If your account is on multiple teams and you need to tell the transporter which provider to use, you can set `:itc_provider` or `:provider_public_id`. */ public func deliver(apiKeyPath: OptionalConfigValue = .fastlaneDefault(deliverfile.apiKeyPath), apiKey: OptionalConfigValue<[String: Any]?> = .fastlaneDefault(deliverfile.apiKey), @@ -3794,6 +3869,9 @@ public func deliver(apiKeyPath: OptionalConfigValue = .fastlaneDefault( useLiveVersion: OptionalConfigValue = .fastlaneDefault(deliverfile.useLiveVersion), metadataPath: OptionalConfigValue = .fastlaneDefault(deliverfile.metadataPath), screenshotsPath: OptionalConfigValue = .fastlaneDefault(deliverfile.screenshotsPath), + appPreviewsPath: OptionalConfigValue = .fastlaneDefault(deliverfile.appPreviewsPath), + previewFrameTimeCode: String = deliverfile.previewFrameTimeCode, + overwritePreviewVideos: OptionalConfigValue = .fastlaneDefault(deliverfile.overwritePreviewVideos), skipBinaryUpload: OptionalConfigValue = .fastlaneDefault(deliverfile.skipBinaryUpload), skipScreenshots: OptionalConfigValue = .fastlaneDefault(deliverfile.skipScreenshots), skipMetadata: OptionalConfigValue = .fastlaneDefault(deliverfile.skipMetadata), @@ -3818,6 +3896,7 @@ public func deliver(apiKeyPath: OptionalConfigValue = .fastlaneDefault( devPortalTeamId: OptionalConfigValue = .fastlaneDefault(deliverfile.devPortalTeamId), devPortalTeamName: OptionalConfigValue = .fastlaneDefault(deliverfile.devPortalTeamName), itcProvider: OptionalConfigValue = .fastlaneDefault(deliverfile.itcProvider), + providerPublicId: OptionalConfigValue = .fastlaneDefault(deliverfile.providerPublicId), runPrecheckBeforeSubmit: OptionalConfigValue = .fastlaneDefault(deliverfile.runPrecheckBeforeSubmit), precheckDefaultRuleLevel: Any = deliverfile.precheckDefaultRuleLevel, individualMetadataItems: OptionalConfigValue<[String]?> = .fastlaneDefault(deliverfile.individualMetadataItems), @@ -3861,6 +3940,9 @@ public func deliver(apiKeyPath: OptionalConfigValue = .fastlaneDefault( let useLiveVersionArg = useLiveVersion.asRubyArgument(name: "use_live_version", type: nil) let metadataPathArg = metadataPath.asRubyArgument(name: "metadata_path", type: nil) let screenshotsPathArg = screenshotsPath.asRubyArgument(name: "screenshots_path", type: nil) + let appPreviewsPathArg = appPreviewsPath.asRubyArgument(name: "app_previews_path", type: nil) + let previewFrameTimeCodeArg = RubyCommand.Argument(name: "preview_frame_time_code", value: previewFrameTimeCode, type: nil) + let overwritePreviewVideosArg = overwritePreviewVideos.asRubyArgument(name: "overwrite_preview_videos", type: nil) let skipBinaryUploadArg = skipBinaryUpload.asRubyArgument(name: "skip_binary_upload", type: nil) let skipScreenshotsArg = skipScreenshots.asRubyArgument(name: "skip_screenshots", type: nil) let skipMetadataArg = skipMetadata.asRubyArgument(name: "skip_metadata", type: nil) @@ -3885,6 +3967,7 @@ public func deliver(apiKeyPath: OptionalConfigValue = .fastlaneDefault( let devPortalTeamIdArg = devPortalTeamId.asRubyArgument(name: "dev_portal_team_id", type: nil) let devPortalTeamNameArg = devPortalTeamName.asRubyArgument(name: "dev_portal_team_name", type: nil) let itcProviderArg = itcProvider.asRubyArgument(name: "itc_provider", type: nil) + let providerPublicIdArg = providerPublicId.asRubyArgument(name: "provider_public_id", type: nil) let runPrecheckBeforeSubmitArg = runPrecheckBeforeSubmit.asRubyArgument(name: "run_precheck_before_submit", type: nil) let precheckDefaultRuleLevelArg = RubyCommand.Argument(name: "precheck_default_rule_level", value: precheckDefaultRuleLevel, type: nil) let individualMetadataItemsArg = individualMetadataItems.asRubyArgument(name: "individual_metadata_items", type: nil) @@ -3927,6 +4010,9 @@ public func deliver(apiKeyPath: OptionalConfigValue = .fastlaneDefault( useLiveVersionArg, metadataPathArg, screenshotsPathArg, + appPreviewsPathArg, + previewFrameTimeCodeArg, + overwritePreviewVideosArg, skipBinaryUploadArg, skipScreenshotsArg, skipMetadataArg, @@ -3951,6 +4037,7 @@ public func deliver(apiKeyPath: OptionalConfigValue = .fastlaneDefault( devPortalTeamIdArg, devPortalTeamNameArg, itcProviderArg, + providerPublicIdArg, runPrecheckBeforeSubmitArg, precheckDefaultRuleLevelArg, individualMetadataItemsArg, @@ -5672,6 +5759,7 @@ public func gradle(task: OptionalConfigValue = .fastlaneDefault(nil), - clean: Should the project be cleaned before building it? - outputDirectory: The directory in which the ipa file should be stored in - outputName: The name of the resulting ipa file + - appName: App name to use in logfile name - configuration: The configuration to use when building the app. Defaults to 'Release' - silent: Hide all information that's not necessary while building - codesigningIdentity: The name of the code signing identity to use. It has to match the name exactly. e.g. 'iPhone Distribution: SunApps GmbH' @@ -5713,10 +5801,13 @@ public func gradle(task: OptionalConfigValue = .fastlaneDefault(nil), - skipProfileDetection: Do not try to build a profile mapping from the xcodeproj. Match or a manually provided mapping should be used - xcodebuildCommand: Allows for override of the default `xcodebuild` command - clonedSourcePackagesPath: Sets a custom path for Swift Package Manager dependencies + - packageCachePath: Sets a custom package cache path for Swift Package Manager dependencies - skipPackageDependenciesResolution: Skips resolution of Swift Package Manager dependencies - - disablePackageAutomaticUpdates: Prevents packages from automatically being resolved to versions other than those recorded in the `Package.resolved` file + - disablePackageAutomaticUpdates: Prevents packages from automatically being resolved to versions other than those recorded in the `Package.resolved` file. This translates in the option `-disableAutomaticPackageResolution` being passed to xcodebuild + - skipPackageRepositoryFetches: Skips updating package dependencies from their remote. This translates in the option `-skipPackageUpdates` being passed to xcodebuild - useSystemScm: Lets xcodebuild use system's scm configuration - packageAuthorizationProvider: Lets xcodebuild use a specified package authorization provider (keychain|netrc) + - generateAppstoreInfo: Generate AppStoreInfo.plist using swinfo for app-store exports - returns: The absolute path to the generated ipa file @@ -5728,6 +5819,7 @@ public func gradle(task: OptionalConfigValue = .fastlaneDefault(nil), clean: OptionalConfigValue = .fastlaneDefault(gymfile.clean), outputDirectory: String = gymfile.outputDirectory, outputName: OptionalConfigValue = .fastlaneDefault(gymfile.outputName), + appName: OptionalConfigValue = .fastlaneDefault(gymfile.appName), configuration: OptionalConfigValue = .fastlaneDefault(gymfile.configuration), silent: OptionalConfigValue = .fastlaneDefault(gymfile.silent), codesigningIdentity: OptionalConfigValue = .fastlaneDefault(gymfile.codesigningIdentity), @@ -5769,10 +5861,13 @@ public func gradle(task: OptionalConfigValue = .fastlaneDefault(nil), skipProfileDetection: OptionalConfigValue = .fastlaneDefault(gymfile.skipProfileDetection), xcodebuildCommand: String = gymfile.xcodebuildCommand, clonedSourcePackagesPath: OptionalConfigValue = .fastlaneDefault(gymfile.clonedSourcePackagesPath), + packageCachePath: OptionalConfigValue = .fastlaneDefault(gymfile.packageCachePath), skipPackageDependenciesResolution: OptionalConfigValue = .fastlaneDefault(gymfile.skipPackageDependenciesResolution), disablePackageAutomaticUpdates: OptionalConfigValue = .fastlaneDefault(gymfile.disablePackageAutomaticUpdates), + skipPackageRepositoryFetches: OptionalConfigValue = .fastlaneDefault(gymfile.skipPackageRepositoryFetches), useSystemScm: OptionalConfigValue = .fastlaneDefault(gymfile.useSystemScm), - packageAuthorizationProvider: OptionalConfigValue = .fastlaneDefault(gymfile.packageAuthorizationProvider)) -> String + packageAuthorizationProvider: OptionalConfigValue = .fastlaneDefault(gymfile.packageAuthorizationProvider), + generateAppstoreInfo: OptionalConfigValue = .fastlaneDefault(gymfile.generateAppstoreInfo)) -> String { let workspaceArg = workspace.asRubyArgument(name: "workspace", type: nil) let projectArg = project.asRubyArgument(name: "project", type: nil) @@ -5780,6 +5875,7 @@ public func gradle(task: OptionalConfigValue = .fastlaneDefault(nil), let cleanArg = clean.asRubyArgument(name: "clean", type: nil) let outputDirectoryArg = RubyCommand.Argument(name: "output_directory", value: outputDirectory, type: nil) let outputNameArg = outputName.asRubyArgument(name: "output_name", type: nil) + let appNameArg = appName.asRubyArgument(name: "app_name", type: nil) let configurationArg = configuration.asRubyArgument(name: "configuration", type: nil) let silentArg = silent.asRubyArgument(name: "silent", type: nil) let codesigningIdentityArg = codesigningIdentity.asRubyArgument(name: "codesigning_identity", type: nil) @@ -5821,16 +5917,20 @@ public func gradle(task: OptionalConfigValue = .fastlaneDefault(nil), let skipProfileDetectionArg = skipProfileDetection.asRubyArgument(name: "skip_profile_detection", type: nil) let xcodebuildCommandArg = RubyCommand.Argument(name: "xcodebuild_command", value: xcodebuildCommand, type: nil) let clonedSourcePackagesPathArg = clonedSourcePackagesPath.asRubyArgument(name: "cloned_source_packages_path", type: nil) + let packageCachePathArg = packageCachePath.asRubyArgument(name: "package_cache_path", type: nil) let skipPackageDependenciesResolutionArg = skipPackageDependenciesResolution.asRubyArgument(name: "skip_package_dependencies_resolution", type: nil) let disablePackageAutomaticUpdatesArg = disablePackageAutomaticUpdates.asRubyArgument(name: "disable_package_automatic_updates", type: nil) + let skipPackageRepositoryFetchesArg = skipPackageRepositoryFetches.asRubyArgument(name: "skip_package_repository_fetches", type: nil) let useSystemScmArg = useSystemScm.asRubyArgument(name: "use_system_scm", type: nil) let packageAuthorizationProviderArg = packageAuthorizationProvider.asRubyArgument(name: "package_authorization_provider", type: nil) + let generateAppstoreInfoArg = generateAppstoreInfo.asRubyArgument(name: "generate_appstore_info", type: nil) let array: [RubyCommand.Argument?] = [workspaceArg, projectArg, schemeArg, cleanArg, outputDirectoryArg, outputNameArg, + appNameArg, configurationArg, silentArg, codesigningIdentityArg, @@ -5872,10 +5972,13 @@ public func gradle(task: OptionalConfigValue = .fastlaneDefault(nil), skipProfileDetectionArg, xcodebuildCommandArg, clonedSourcePackagesPathArg, + packageCachePathArg, skipPackageDependenciesResolutionArg, disablePackageAutomaticUpdatesArg, + skipPackageRepositoryFetchesArg, useSystemScmArg, - packageAuthorizationProviderArg] + packageAuthorizationProviderArg, + generateAppstoreInfoArg] let args: [RubyCommand.Argument] = array .filter { $0?.value != nil } .compactMap { $0 } @@ -6143,6 +6246,7 @@ public func ifttt(apiKey: String, - parameters: - certificatePath: Path to certificate - certificatePassword: Certificate password + - certificateFormat: Format of the certificate. Check the '-f' switch from 'security import --help' command - keychainName: Keychain the items should be imported to - keychainPath: Path to the Keychain file to which the items should be imported - keychainPassword: The password for the keychain. Note that for the login keychain this is your user's password @@ -6152,6 +6256,7 @@ public func ifttt(apiKey: String, */ public func importCertificate(certificatePath: String, certificatePassword: OptionalConfigValue = .fastlaneDefault(nil), + certificateFormat: OptionalConfigValue = .fastlaneDefault(nil), keychainName: String, keychainPath: OptionalConfigValue = .fastlaneDefault(nil), keychainPassword: OptionalConfigValue = .fastlaneDefault(nil), @@ -6159,12 +6264,14 @@ public func importCertificate(certificatePath: String, { let certificatePathArg = RubyCommand.Argument(name: "certificate_path", value: certificatePath, type: nil) let certificatePasswordArg = certificatePassword.asRubyArgument(name: "certificate_password", type: nil) + let certificateFormatArg = certificateFormat.asRubyArgument(name: "certificate_format", type: nil) let keychainNameArg = RubyCommand.Argument(name: "keychain_name", value: keychainName, type: nil) let keychainPathArg = keychainPath.asRubyArgument(name: "keychain_path", type: nil) let keychainPasswordArg = keychainPassword.asRubyArgument(name: "keychain_password", type: nil) let logOutputArg = logOutput.asRubyArgument(name: "log_output", type: nil) let array: [RubyCommand.Argument?] = [certificatePathArg, certificatePasswordArg, + certificateFormatArg, keychainNameArg, keychainPathArg, keychainPasswordArg, @@ -6530,7 +6637,7 @@ public func jazzy(config: OptionalConfigValue = .fastlaneDefault(nil), - username: Your Apple ID Username - version: The version number whose latest build number we want - platform: The platform to use (optional) - - initialBuildNumber: sets the build number to given value if no build is in current train + - initialBuildNumber: sets the build number to given value if no build (upload) is in current train - teamId: The ID of your App Store Connect team if you're in multiple teams - teamName: The name of your App Store Connect team if you're in multiple teams @@ -6738,6 +6845,7 @@ public func makeChangelogFromJenkins(fallbackChangelog: String = "", - s3Region: Name of the S3 region - s3AccessKey: S3 access key - s3SecretAccessKey: S3 secret access key + - s3SessionToken: S3 session token - s3Bucket: Name of the S3 bucket - s3ObjectPrefix: Prefix to be used on all objects uploaded to S3 - s3SkipEncryption: Skip encryption of all objects uploaded to S3. WARNING: only enable this on S3 buckets with sufficiently restricted permissions and server-side encryption enabled. See https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingEncryption.html @@ -6797,6 +6905,7 @@ public func match(type: String = matchfile.type, s3Region: OptionalConfigValue = .fastlaneDefault(matchfile.s3Region), s3AccessKey: OptionalConfigValue = .fastlaneDefault(matchfile.s3AccessKey), s3SecretAccessKey: OptionalConfigValue = .fastlaneDefault(matchfile.s3SecretAccessKey), + s3SessionToken: OptionalConfigValue = .fastlaneDefault(matchfile.s3SessionToken), s3Bucket: OptionalConfigValue = .fastlaneDefault(matchfile.s3Bucket), s3ObjectPrefix: OptionalConfigValue = .fastlaneDefault(matchfile.s3ObjectPrefix), s3SkipEncryption: OptionalConfigValue = .fastlaneDefault(matchfile.s3SkipEncryption), @@ -6854,6 +6963,7 @@ public func match(type: String = matchfile.type, let s3RegionArg = s3Region.asRubyArgument(name: "s3_region", type: nil) let s3AccessKeyArg = s3AccessKey.asRubyArgument(name: "s3_access_key", type: nil) let s3SecretAccessKeyArg = s3SecretAccessKey.asRubyArgument(name: "s3_secret_access_key", type: nil) + let s3SessionTokenArg = s3SessionToken.asRubyArgument(name: "s3_session_token", type: nil) let s3BucketArg = s3Bucket.asRubyArgument(name: "s3_bucket", type: nil) let s3ObjectPrefixArg = s3ObjectPrefix.asRubyArgument(name: "s3_object_prefix", type: nil) let s3SkipEncryptionArg = s3SkipEncryption.asRubyArgument(name: "s3_skip_encryption", type: nil) @@ -6910,6 +7020,7 @@ public func match(type: String = matchfile.type, s3RegionArg, s3AccessKeyArg, s3SecretAccessKeyArg, + s3SessionTokenArg, s3BucketArg, s3ObjectPrefixArg, s3SkipEncryptionArg, @@ -6977,6 +7088,7 @@ public func match(type: String = matchfile.type, - s3Region: Name of the S3 region - s3AccessKey: S3 access key - s3SecretAccessKey: S3 secret access key + - s3SessionToken: S3 session token - s3Bucket: Name of the S3 bucket - s3ObjectPrefix: Prefix to be used on all objects uploaded to S3 - s3SkipEncryption: Skip encryption of all objects uploaded to S3. WARNING: only enable this on S3 buckets with sufficiently restricted permissions and server-side encryption enabled. See https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingEncryption.html @@ -7040,6 +7152,7 @@ public func matchNuke(type: String = "development", s3Region: OptionalConfigValue = .fastlaneDefault(nil), s3AccessKey: OptionalConfigValue = .fastlaneDefault(nil), s3SecretAccessKey: OptionalConfigValue = .fastlaneDefault(nil), + s3SessionToken: OptionalConfigValue = .fastlaneDefault(nil), s3Bucket: OptionalConfigValue = .fastlaneDefault(nil), s3ObjectPrefix: OptionalConfigValue = .fastlaneDefault(nil), s3SkipEncryption: OptionalConfigValue = .fastlaneDefault(false), @@ -7097,6 +7210,7 @@ public func matchNuke(type: String = "development", let s3RegionArg = s3Region.asRubyArgument(name: "s3_region", type: nil) let s3AccessKeyArg = s3AccessKey.asRubyArgument(name: "s3_access_key", type: nil) let s3SecretAccessKeyArg = s3SecretAccessKey.asRubyArgument(name: "s3_secret_access_key", type: nil) + let s3SessionTokenArg = s3SessionToken.asRubyArgument(name: "s3_session_token", type: nil) let s3BucketArg = s3Bucket.asRubyArgument(name: "s3_bucket", type: nil) let s3ObjectPrefixArg = s3ObjectPrefix.asRubyArgument(name: "s3_object_prefix", type: nil) let s3SkipEncryptionArg = s3SkipEncryption.asRubyArgument(name: "s3_skip_encryption", type: nil) @@ -7153,6 +7267,7 @@ public func matchNuke(type: String = "development", s3RegionArg, s3AccessKeyArg, s3SecretAccessKeyArg, + s3SessionTokenArg, s3BucketArg, s3ObjectPrefixArg, s3SkipEncryptionArg, @@ -7206,7 +7321,7 @@ public func minFastlaneVersion() { - parameters: - username: Your Apple ID Username - appIdentifier: App Identifier (Bundle ID, e.g. com.krausefx.app) - - services: Array with Spaceship App Services (e.g. access_wifi: (on|off)(:on|:off)(true|false), app_attest: (on|off)(:on|:off)(true|false), app_group: (on|off)(:on|:off)(true|false), apple_pay: (on|off)(:on|:off)(true|false), associated_domains: (on|off)(:on|:off)(true|false), auto_fill_credential: (on|off)(:on|:off)(true|false), class_kit: (on|off)(:on|:off)(true|false), icloud: (legacy|cloudkit)(:on|:off)(true|false), custom_network_protocol: (on|off)(:on|:off)(true|false), data_protection: (complete|unlessopen|untilfirstauth)(:on|:off)(true|false), extended_virtual_address_space: (on|off)(:on|:off)(true|false), family_controls: (on|off)(:on|:off)(true|false), file_provider_testing_mode: (on|off)(:on|:off)(true|false), fonts: (on|off)(:on|:off)(true|false), game_center: (ios|mac)(:on|:off)(true|false), health_kit: (on|off)(:on|:off)(true|false), hls_interstitial_preview: (on|off)(:on|:off)(true|false), home_kit: (on|off)(:on|:off)(true|false), hotspot: (on|off)(:on|:off)(true|false), in_app_purchase: (on|off)(:on|:off)(true|false), inter_app_audio: (on|off)(:on|:off)(true|false), low_latency_hls: (on|off)(:on|:off)(true|false), managed_associated_domains: (on|off)(:on|:off)(true|false), maps: (on|off)(:on|:off)(true|false), multipath: (on|off)(:on|:off)(true|false), network_extension: (on|off)(:on|:off)(true|false), nfc_tag_reading: (on|off)(:on|:off)(true|false), personal_vpn: (on|off)(:on|:off)(true|false), passbook: (on|off)(:on|:off)(true|false), push_notification: (on|off)(:on|:off)(true|false), sign_in_with_apple: (on)(:on|:off)(true|false), siri_kit: (on|off)(:on|:off)(true|false), system_extension: (on|off)(:on|:off)(true|false), user_management: (on|off)(:on|:off)(true|false), vpn_configuration: (on|off)(:on|:off)(true|false), wallet: (on|off)(:on|:off)(true|false), wireless_accessory: (on|off)(:on|:off)(true|false), car_play_audio_app: (on|off)(:on|:off)(true|false), car_play_messaging_app: (on|off)(:on|:off)(true|false), car_play_navigation_app: (on|off)(:on|:off)(true|false), car_play_voip_calling_app: (on|off)(:on|:off)(true|false), critical_alerts: (on|off)(:on|:off)(true|false), hotspot_helper: (on|off)(:on|:off)(true|false), driver_kit: (on|off)(:on|:off)(true|false), driver_kit_endpoint_security: (on|off)(:on|:off)(true|false), driver_kit_family_hid_device: (on|off)(:on|:off)(true|false), driver_kit_family_networking: (on|off)(:on|:off)(true|false), driver_kit_family_serial: (on|off)(:on|:off)(true|false), driver_kit_hid_event_service: (on|off)(:on|:off)(true|false), driver_kit_transport_hid: (on|off)(:on|:off)(true|false), multitasking_camera_access: (on|off)(:on|:off)(true|false), sf_universal_link_api: (on|off)(:on|:off)(true|false), vp9_decoder: (on|off)(:on|:off)(true|false), music_kit: (on|off)(:on|:off)(true|false), shazam_kit: (on|off)(:on|:off)(true|false), communication_notifications: (on|off)(:on|:off)(true|false), group_activities: (on|off)(:on|:off)(true|false), health_kit_estimate_recalibration: (on|off)(:on|:off)(true|false), time_sensitive_notifications: (on|off)(:on|:off)(true|false)) + - services: Array with Spaceship App Services (e.g. access_wifi: (on|off)(:on|:off)(true|false), app_attest: (on|off)(:on|:off)(true|false), app_group: (on|off)(:on|:off)(true|false), apple_pay: (on|off)(:on|:off)(true|false), associated_domains: (on|off)(:on|:off)(true|false), auto_fill_credential: (on|off)(:on|:off)(true|false), class_kit: (on|off)(:on|:off)(true|false), declared_age_range: (on|off)(:on|:off)(true|false), icloud: (legacy|cloudkit)(:on|:off)(true|false), custom_network_protocol: (on|off)(:on|:off)(true|false), data_protection: (complete|unlessopen|untilfirstauth)(:on|:off)(true|false), extended_virtual_address_space: (on|off)(:on|:off)(true|false), family_controls: (on|off)(:on|:off)(true|false), file_provider_testing_mode: (on|off)(:on|:off)(true|false), fonts: (on|off)(:on|:off)(true|false), game_center: (ios|mac)(:on|:off)(true|false), health_kit: (on|off)(:on|:off)(true|false), hls_interstitial_preview: (on|off)(:on|:off)(true|false), home_kit: (on|off)(:on|:off)(true|false), hotspot: (on|off)(:on|:off)(true|false), in_app_purchase: (on|off)(:on|:off)(true|false), inter_app_audio: (on|off)(:on|:off)(true|false), low_latency_hls: (on|off)(:on|:off)(true|false), managed_associated_domains: (on|off)(:on|:off)(true|false), maps: (on|off)(:on|:off)(true|false), multipath: (on|off)(:on|:off)(true|false), network_extension: (on|off)(:on|:off)(true|false), nfc_tag_reading: (on|off)(:on|:off)(true|false), personal_vpn: (on|off)(:on|:off)(true|false), passbook: (on|off)(:on|:off)(true|false), push_notification: (on|off)(:on|:off)(true|false), sign_in_with_apple: (on)(:on|:off)(true|false), siri_kit: (on|off)(:on|:off)(true|false), system_extension: (on|off)(:on|:off)(true|false), user_management: (on|off)(:on|:off)(true|false), vpn_configuration: (on|off)(:on|:off)(true|false), wallet: (on|off)(:on|:off)(true|false), wireless_accessory: (on|off)(:on|:off)(true|false), car_play_audio_app: (on|off)(:on|:off)(true|false), car_play_messaging_app: (on|off)(:on|:off)(true|false), car_play_navigation_app: (on|off)(:on|:off)(true|false), car_play_voip_calling_app: (on|off)(:on|:off)(true|false), critical_alerts: (on|off)(:on|:off)(true|false), hotspot_helper: (on|off)(:on|:off)(true|false), driver_kit: (on|off)(:on|:off)(true|false), driver_kit_endpoint_security: (on|off)(:on|:off)(true|false), driver_kit_family_hid_device: (on|off)(:on|:off)(true|false), driver_kit_family_networking: (on|off)(:on|:off)(true|false), driver_kit_family_serial: (on|off)(:on|:off)(true|false), driver_kit_hid_event_service: (on|off)(:on|:off)(true|false), driver_kit_transport_hid: (on|off)(:on|:off)(true|false), multitasking_camera_access: (on|off)(:on|:off)(true|false), sf_universal_link_api: (on|off)(:on|:off)(true|false), vp9_decoder: (on|off)(:on|:off)(true|false), music_kit: (on|off)(:on|:off)(true|false), shazam_kit: (on|off)(:on|:off)(true|false), communication_notifications: (on|off)(:on|:off)(true|false), group_activities: (on|off)(:on|:off)(true|false), health_kit_estimate_recalibration: (on|off)(:on|:off)(true|false), time_sensitive_notifications: (on|off)(:on|:off)(true|false)) - teamId: The ID of your Developer Portal team if you're in multiple teams - teamName: The name of your Developer Portal team if you're in multiple teams @@ -7743,6 +7858,7 @@ public func pem(platform: String = "ios", - teamName: The name of your App Store Connect team if you're in multiple teams - devPortalTeamId: The short ID of your team in the developer portal, if you're in multiple teams. Different from your iTC team ID! - itcProvider: The provider short name to be used with the iTMSTransporter to identify your team. This value will override the automatically detected provider short name. To get provider short name run `pathToXcode.app/Contents/Applications/Application\ Loader.app/Contents/itms/bin/iTMSTransporter -m provider -u 'USERNAME' -p 'PASSWORD' -account_type itunes_connect -v off`. The short names of providers should be listed in the second column + - providerPublicId: The provider public ID to be used with altool (--provider-public-id). This value will override the automatically detected provider value for altool uploads. Required after Xcode 26 when your account is associated with multiple providers and using username/app-password authentication - waitProcessingInterval: Interval in seconds to wait for App Store Connect processing - waitProcessingTimeoutDuration: Timeout duration in seconds to wait for App Store Connect processing. If set, after exceeding timeout duration, this will `force stop` to wait for App Store Connect processing and exit with exception - waitForUploadedBuild: **DEPRECATED!** No longer needed with the transition over to the App Store Connect API - Use version info from uploaded ipa file to determine what build to use for distribution. If set to false, latest processing or any latest build will be used @@ -7786,6 +7902,7 @@ public func pilot(apiKeyPath: OptionalConfigValue = .fastlaneDefault(ni teamName: OptionalConfigValue = .fastlaneDefault(nil), devPortalTeamId: OptionalConfigValue = .fastlaneDefault(nil), itcProvider: OptionalConfigValue = .fastlaneDefault(nil), + providerPublicId: OptionalConfigValue = .fastlaneDefault(nil), waitProcessingInterval: Int = 30, waitProcessingTimeoutDuration: OptionalConfigValue = .fastlaneDefault(nil), waitForUploadedBuild: OptionalConfigValue = .fastlaneDefault(false), @@ -7826,6 +7943,7 @@ public func pilot(apiKeyPath: OptionalConfigValue = .fastlaneDefault(ni let teamNameArg = teamName.asRubyArgument(name: "team_name", type: nil) let devPortalTeamIdArg = devPortalTeamId.asRubyArgument(name: "dev_portal_team_id", type: nil) let itcProviderArg = itcProvider.asRubyArgument(name: "itc_provider", type: nil) + let providerPublicIdArg = providerPublicId.asRubyArgument(name: "provider_public_id", type: nil) let waitProcessingIntervalArg = RubyCommand.Argument(name: "wait_processing_interval", value: waitProcessingInterval, type: nil) let waitProcessingTimeoutDurationArg = waitProcessingTimeoutDuration.asRubyArgument(name: "wait_processing_timeout_duration", type: nil) let waitForUploadedBuildArg = waitForUploadedBuild.asRubyArgument(name: "wait_for_uploaded_build", type: nil) @@ -7865,6 +7983,7 @@ public func pilot(apiKeyPath: OptionalConfigValue = .fastlaneDefault(ni teamNameArg, devPortalTeamIdArg, itcProviderArg, + providerPublicIdArg, waitProcessingIntervalArg, waitProcessingTimeoutDurationArg, waitForUploadedBuildArg, @@ -8208,7 +8327,7 @@ public func println(message: OptionalConfigValue = .fastlaneDefault(nil - skipItc: Skip the creation of the app on App Store Connect - itcUsers: Array of App Store Connect users. If provided, you can limit access to this newly created app for users with the App Manager, Developer, Marketer or Sales roles - enabledFeatures: **DEPRECATED!** Please use `enable_services` instead - Array with Spaceship App Services - - enableServices: Array with Spaceship App Services (e.g. access_wifi: (on|off), app_attest: (on|off), app_group: (on|off), apple_pay: (on|off), associated_domains: (on|off), auto_fill_credential: (on|off), class_kit: (on|off), icloud: (legacy|cloudkit), custom_network_protocol: (on|off), data_protection: (complete|unlessopen|untilfirstauth), extended_virtual_address_space: (on|off), family_controls: (on|off), file_provider_testing_mode: (on|off), fonts: (on|off), game_center: (ios|mac), health_kit: (on|off), hls_interstitial_preview: (on|off), home_kit: (on|off), hotspot: (on|off), in_app_purchase: (on|off), inter_app_audio: (on|off), low_latency_hls: (on|off), managed_associated_domains: (on|off), maps: (on|off), multipath: (on|off), network_extension: (on|off), nfc_tag_reading: (on|off), personal_vpn: (on|off), passbook: (on|off), push_notification: (on|off), sign_in_with_apple: (on), siri_kit: (on|off), system_extension: (on|off), user_management: (on|off), vpn_configuration: (on|off), wallet: (on|off), wireless_accessory: (on|off), car_play_audio_app: (on|off), car_play_messaging_app: (on|off), car_play_navigation_app: (on|off), car_play_voip_calling_app: (on|off), critical_alerts: (on|off), hotspot_helper: (on|off), driver_kit: (on|off), driver_kit_endpoint_security: (on|off), driver_kit_family_hid_device: (on|off), driver_kit_family_networking: (on|off), driver_kit_family_serial: (on|off), driver_kit_hid_event_service: (on|off), driver_kit_transport_hid: (on|off), multitasking_camera_access: (on|off), sf_universal_link_api: (on|off), vp9_decoder: (on|off), music_kit: (on|off), shazam_kit: (on|off), communication_notifications: (on|off), group_activities: (on|off), health_kit_estimate_recalibration: (on|off), time_sensitive_notifications: (on|off)) + - enableServices: Array with Spaceship App Services (e.g. access_wifi: (on|off), app_attest: (on|off), app_group: (on|off), apple_pay: (on|off), associated_domains: (on|off), auto_fill_credential: (on|off), class_kit: (on|off), declared_age_range: (on|off), icloud: (legacy|cloudkit), custom_network_protocol: (on|off), data_protection: (complete|unlessopen|untilfirstauth), extended_virtual_address_space: (on|off), family_controls: (on|off), file_provider_testing_mode: (on|off), fonts: (on|off), game_center: (ios|mac), health_kit: (on|off), hls_interstitial_preview: (on|off), home_kit: (on|off), hotspot: (on|off), in_app_purchase: (on|off), inter_app_audio: (on|off), low_latency_hls: (on|off), managed_associated_domains: (on|off), maps: (on|off), multipath: (on|off), network_extension: (on|off), nfc_tag_reading: (on|off), personal_vpn: (on|off), passbook: (on|off), push_notification: (on|off), sign_in_with_apple: (on), siri_kit: (on|off), system_extension: (on|off), user_management: (on|off), vpn_configuration: (on|off), wallet: (on|off), wireless_accessory: (on|off), car_play_audio_app: (on|off), car_play_messaging_app: (on|off), car_play_navigation_app: (on|off), car_play_voip_calling_app: (on|off), critical_alerts: (on|off), hotspot_helper: (on|off), driver_kit: (on|off), driver_kit_endpoint_security: (on|off), driver_kit_family_hid_device: (on|off), driver_kit_family_networking: (on|off), driver_kit_family_serial: (on|off), driver_kit_hid_event_service: (on|off), driver_kit_transport_hid: (on|off), multitasking_camera_access: (on|off), sf_universal_link_api: (on|off), vp9_decoder: (on|off), music_kit: (on|off), shazam_kit: (on|off), communication_notifications: (on|off), group_activities: (on|off), health_kit_estimate_recalibration: (on|off), time_sensitive_notifications: (on|off)) - skipDevcenter: Skip the creation of the app on the Apple Developer Portal - teamId: The ID of your Developer Portal team if you're in multiple teams - teamName: The name of your Developer Portal team if you're in multiple teams @@ -8624,6 +8743,7 @@ public func resetSimulatorContents(ios: OptionalConfigValue<[String]?> = .fastla - bundleId: Set new bundle ID during resign (`CFBundleIdentifier`) - useAppEntitlements: Extract app bundle codesigning entitlements and combine with entitlements from new provisioning profile - keychainPath: Provide a path to a keychain file that should be used by `/usr/bin/codesign` + - pagesize: Page size in bytes passed to `/usr/bin/codesign --pagesize` (must be a power of two) */ public func resign(ipa: String, signingIdentity: String, @@ -8635,7 +8755,8 @@ public func resign(ipa: String, bundleVersion: OptionalConfigValue = .fastlaneDefault(nil), bundleId: OptionalConfigValue = .fastlaneDefault(nil), useAppEntitlements: OptionalConfigValue = .fastlaneDefault(nil), - keychainPath: OptionalConfigValue = .fastlaneDefault(nil)) + keychainPath: OptionalConfigValue = .fastlaneDefault(nil), + pagesize: OptionalConfigValue = .fastlaneDefault(nil)) { let ipaArg = RubyCommand.Argument(name: "ipa", value: ipa, type: nil) let signingIdentityArg = RubyCommand.Argument(name: "signing_identity", value: signingIdentity, type: nil) @@ -8648,6 +8769,7 @@ public func resign(ipa: String, let bundleIdArg = bundleId.asRubyArgument(name: "bundle_id", type: nil) let useAppEntitlementsArg = useAppEntitlements.asRubyArgument(name: "use_app_entitlements", type: nil) let keychainPathArg = keychainPath.asRubyArgument(name: "keychain_path", type: nil) + let pagesizeArg = pagesize.asRubyArgument(name: "pagesize", type: nil) let array: [RubyCommand.Argument?] = [ipaArg, signingIdentityArg, entitlementsArg, @@ -8658,7 +8780,8 @@ public func resign(ipa: String, bundleVersionArg, bundleIdArg, useAppEntitlementsArg, - keychainPathArg] + keychainPathArg, + pagesizeArg] let args: [RubyCommand.Argument] = array .filter { $0?.value != nil } .compactMap { $0 } @@ -8826,8 +8949,10 @@ public func rubyVersion() { - customReportFileName: **DEPRECATED!** Use `--output_files` instead - Sets custom full report file name when generating a single report - xcodebuildCommand: Allows for override of the default `xcodebuild` command - clonedSourcePackagesPath: Sets a custom path for Swift Package Manager dependencies + - packageCachePath: Sets a custom package cache path for Swift Package Manager dependencies - skipPackageDependenciesResolution: Skips resolution of Swift Package Manager dependencies - - disablePackageAutomaticUpdates: Prevents packages from automatically being resolved to versions other than those recorded in the `Package.resolved` file + - disablePackageAutomaticUpdates: Prevents packages from automatically being resolved to versions other than those recorded in the `Package.resolved` file. This translates in the option `-disableAutomaticPackageResolution` being passed to xcodebuild + - skipPackageRepositoryFetches: Skips updating package dependencies from their remote. This translates in the option `-skipPackageUpdates` being passed to xcodebuild - useSystemScm: Lets xcodebuild use system's scm configuration - numberOfRetries: The number of times a test can fail - failBuild: Should this step stop the build if the tests fail? Set this to false if you're using trainer @@ -8910,8 +9035,10 @@ public func rubyVersion() { customReportFileName: OptionalConfigValue = .fastlaneDefault(nil), xcodebuildCommand: String = "env NSUnbufferedIO=YES xcodebuild", clonedSourcePackagesPath: OptionalConfigValue = .fastlaneDefault(nil), + packageCachePath: OptionalConfigValue = .fastlaneDefault(nil), skipPackageDependenciesResolution: OptionalConfigValue = .fastlaneDefault(false), disablePackageAutomaticUpdates: OptionalConfigValue = .fastlaneDefault(false), + skipPackageRepositoryFetches: OptionalConfigValue = .fastlaneDefault(false), useSystemScm: OptionalConfigValue = .fastlaneDefault(false), numberOfRetries: Int = 0, failBuild: OptionalConfigValue = .fastlaneDefault(true), @@ -8990,8 +9117,10 @@ public func rubyVersion() { let customReportFileNameArg = customReportFileName.asRubyArgument(name: "custom_report_file_name", type: nil) let xcodebuildCommandArg = RubyCommand.Argument(name: "xcodebuild_command", value: xcodebuildCommand, type: nil) let clonedSourcePackagesPathArg = clonedSourcePackagesPath.asRubyArgument(name: "cloned_source_packages_path", type: nil) + let packageCachePathArg = packageCachePath.asRubyArgument(name: "package_cache_path", type: nil) let skipPackageDependenciesResolutionArg = skipPackageDependenciesResolution.asRubyArgument(name: "skip_package_dependencies_resolution", type: nil) let disablePackageAutomaticUpdatesArg = disablePackageAutomaticUpdates.asRubyArgument(name: "disable_package_automatic_updates", type: nil) + let skipPackageRepositoryFetchesArg = skipPackageRepositoryFetches.asRubyArgument(name: "skip_package_repository_fetches", type: nil) let useSystemScmArg = useSystemScm.asRubyArgument(name: "use_system_scm", type: nil) let numberOfRetriesArg = RubyCommand.Argument(name: "number_of_retries", value: numberOfRetries, type: nil) let failBuildArg = failBuild.asRubyArgument(name: "fail_build", type: nil) @@ -9069,8 +9198,10 @@ public func rubyVersion() { customReportFileNameArg, xcodebuildCommandArg, clonedSourcePackagesPathArg, + packageCachePathArg, skipPackageDependenciesResolutionArg, disablePackageAutomaticUpdatesArg, + skipPackageRepositoryFetchesArg, useSystemScmArg, numberOfRetriesArg, failBuildArg, @@ -9261,8 +9392,10 @@ public func say(text: [String], - customReportFileName: **DEPRECATED!** Use `--output_files` instead - Sets custom full report file name when generating a single report - xcodebuildCommand: Allows for override of the default `xcodebuild` command - clonedSourcePackagesPath: Sets a custom path for Swift Package Manager dependencies + - packageCachePath: Sets a custom package cache path for Swift Package Manager dependencies - skipPackageDependenciesResolution: Skips resolution of Swift Package Manager dependencies - - disablePackageAutomaticUpdates: Prevents packages from automatically being resolved to versions other than those recorded in the `Package.resolved` file + - disablePackageAutomaticUpdates: Prevents packages from automatically being resolved to versions other than those recorded in the `Package.resolved` file. This translates in the option `-disableAutomaticPackageResolution` being passed to xcodebuild + - skipPackageRepositoryFetches: Skips updating package dependencies from their remote. This translates in the option `-skipPackageUpdates` being passed to xcodebuild - useSystemScm: Lets xcodebuild use system's scm configuration - numberOfRetries: The number of times a test can fail - failBuild: Should this step stop the build if the tests fail? Set this to false if you're using trainer @@ -9345,8 +9478,10 @@ public func say(text: [String], customReportFileName: OptionalConfigValue = .fastlaneDefault(scanfile.customReportFileName), xcodebuildCommand: String = scanfile.xcodebuildCommand, clonedSourcePackagesPath: OptionalConfigValue = .fastlaneDefault(scanfile.clonedSourcePackagesPath), + packageCachePath: OptionalConfigValue = .fastlaneDefault(scanfile.packageCachePath), skipPackageDependenciesResolution: OptionalConfigValue = .fastlaneDefault(scanfile.skipPackageDependenciesResolution), disablePackageAutomaticUpdates: OptionalConfigValue = .fastlaneDefault(scanfile.disablePackageAutomaticUpdates), + skipPackageRepositoryFetches: OptionalConfigValue = .fastlaneDefault(scanfile.skipPackageRepositoryFetches), useSystemScm: OptionalConfigValue = .fastlaneDefault(scanfile.useSystemScm), numberOfRetries: Int = scanfile.numberOfRetries, failBuild: OptionalConfigValue = .fastlaneDefault(scanfile.failBuild), @@ -9425,8 +9560,10 @@ public func say(text: [String], let customReportFileNameArg = customReportFileName.asRubyArgument(name: "custom_report_file_name", type: nil) let xcodebuildCommandArg = RubyCommand.Argument(name: "xcodebuild_command", value: xcodebuildCommand, type: nil) let clonedSourcePackagesPathArg = clonedSourcePackagesPath.asRubyArgument(name: "cloned_source_packages_path", type: nil) + let packageCachePathArg = packageCachePath.asRubyArgument(name: "package_cache_path", type: nil) let skipPackageDependenciesResolutionArg = skipPackageDependenciesResolution.asRubyArgument(name: "skip_package_dependencies_resolution", type: nil) let disablePackageAutomaticUpdatesArg = disablePackageAutomaticUpdates.asRubyArgument(name: "disable_package_automatic_updates", type: nil) + let skipPackageRepositoryFetchesArg = skipPackageRepositoryFetches.asRubyArgument(name: "skip_package_repository_fetches", type: nil) let useSystemScmArg = useSystemScm.asRubyArgument(name: "use_system_scm", type: nil) let numberOfRetriesArg = RubyCommand.Argument(name: "number_of_retries", value: numberOfRetries, type: nil) let failBuildArg = failBuild.asRubyArgument(name: "fail_build", type: nil) @@ -9504,8 +9641,10 @@ public func say(text: [String], customReportFileNameArg, xcodebuildCommandArg, clonedSourcePackagesPathArg, + packageCachePathArg, skipPackageDependenciesResolutionArg, disablePackageAutomaticUpdatesArg, + skipPackageRepositoryFetchesArg, useSystemScmArg, numberOfRetriesArg, failBuildArg, @@ -10255,55 +10394,6 @@ public func slack(message: OptionalConfigValue = .fastlaneDefault(nil), _ = runner.executeCommand(command) } -/** - Show a train of the fastlane progress - - - returns: A string that is being sent to slack - */ -public func slackTrain() { - let args: [RubyCommand.Argument] = [] - let command = RubyCommand(commandID: "", methodName: "slack_train", className: nil, args: args) - _ = runner.executeCommand(command) -} - -/** - - */ -public func slackTrainCrash() { - let args: [RubyCommand.Argument] = [] - let command = RubyCommand(commandID: "", methodName: "slack_train_crash", className: nil, args: args) - _ = runner.executeCommand(command) -} - -/** - Show a train of the fastlane progress - - - parameters: - - distance: How many rails do we need? - - train: Train emoji - - rail: Character or emoji for the rail - - reverseDirection: Pass true if you want the train to go from left to right - */ -public func slackTrainStart(distance: Int = 5, - train: String = "🚝", - rail: String = "=", - reverseDirection: OptionalConfigValue = .fastlaneDefault(false)) -{ - let distanceArg = RubyCommand.Argument(name: "distance", value: distance, type: nil) - let trainArg = RubyCommand.Argument(name: "train", value: train, type: nil) - let railArg = RubyCommand.Argument(name: "rail", value: rail, type: nil) - let reverseDirectionArg = reverseDirection.asRubyArgument(name: "reverse_direction", type: nil) - let array: [RubyCommand.Argument?] = [distanceArg, - trainArg, - railArg, - reverseDirectionArg] - let args: [RubyCommand.Argument] = array - .filter { $0?.value != nil } - .compactMap { $0 } - let command = RubyCommand(commandID: "", methodName: "slack_train_start", className: nil, args: args) - _ = runner.executeCommand(command) -} - /** Use slather to generate a code coverage report @@ -10495,8 +10585,10 @@ public func slather(buildDirectory: OptionalConfigValue = .fastlaneDefa - concurrentSimulators: Take snapshots on multiple simulators concurrently. Note: This option is only applicable when running against Xcode 9 - disableSlideToType: Disable the simulator from showing the 'Slide to type' prompt - clonedSourcePackagesPath: Sets a custom path for Swift Package Manager dependencies + - packageCachePath: Sets a custom package cache path for Swift Package Manager dependencies - skipPackageDependenciesResolution: Skips resolution of Swift Package Manager dependencies - - disablePackageAutomaticUpdates: Prevents packages from automatically being resolved to versions other than those recorded in the `Package.resolved` file + - disablePackageAutomaticUpdates: Prevents packages from automatically being resolved to versions other than those recorded in the `Package.resolved` file. This translates in the option `-disableAutomaticPackageResolution` being passed to xcodebuild + - skipPackageRepositoryFetches: Skips updating package dependencies from their remote. This translates in the option `-skipPackageUpdates` being passed to xcodebuild - packageAuthorizationProvider: Lets xcodebuild use a specified package authorization provider (keychain|netrc) - testplan: The testplan associated with the scheme that should be used for testing - onlyTesting: Array of strings matching Test Bundle/Test Suite/Test Cases to run @@ -10546,8 +10638,10 @@ public func snapshot(workspace: OptionalConfigValue = .fastlaneDefault( concurrentSimulators: OptionalConfigValue = .fastlaneDefault(snapshotfile.concurrentSimulators), disableSlideToType: OptionalConfigValue = .fastlaneDefault(snapshotfile.disableSlideToType), clonedSourcePackagesPath: OptionalConfigValue = .fastlaneDefault(snapshotfile.clonedSourcePackagesPath), + packageCachePath: OptionalConfigValue = .fastlaneDefault(snapshotfile.packageCachePath), skipPackageDependenciesResolution: OptionalConfigValue = .fastlaneDefault(snapshotfile.skipPackageDependenciesResolution), disablePackageAutomaticUpdates: OptionalConfigValue = .fastlaneDefault(snapshotfile.disablePackageAutomaticUpdates), + skipPackageRepositoryFetches: OptionalConfigValue = .fastlaneDefault(snapshotfile.skipPackageRepositoryFetches), packageAuthorizationProvider: OptionalConfigValue = .fastlaneDefault(snapshotfile.packageAuthorizationProvider), testplan: OptionalConfigValue = .fastlaneDefault(snapshotfile.testplan), onlyTesting: Any? = snapshotfile.onlyTesting, @@ -10597,8 +10691,10 @@ public func snapshot(workspace: OptionalConfigValue = .fastlaneDefault( let concurrentSimulatorsArg = concurrentSimulators.asRubyArgument(name: "concurrent_simulators", type: nil) let disableSlideToTypeArg = disableSlideToType.asRubyArgument(name: "disable_slide_to_type", type: nil) let clonedSourcePackagesPathArg = clonedSourcePackagesPath.asRubyArgument(name: "cloned_source_packages_path", type: nil) + let packageCachePathArg = packageCachePath.asRubyArgument(name: "package_cache_path", type: nil) let skipPackageDependenciesResolutionArg = skipPackageDependenciesResolution.asRubyArgument(name: "skip_package_dependencies_resolution", type: nil) let disablePackageAutomaticUpdatesArg = disablePackageAutomaticUpdates.asRubyArgument(name: "disable_package_automatic_updates", type: nil) + let skipPackageRepositoryFetchesArg = skipPackageRepositoryFetches.asRubyArgument(name: "skip_package_repository_fetches", type: nil) let packageAuthorizationProviderArg = packageAuthorizationProvider.asRubyArgument(name: "package_authorization_provider", type: nil) let testplanArg = testplan.asRubyArgument(name: "testplan", type: nil) let onlyTestingArg = RubyCommand.Argument(name: "only_testing", value: onlyTesting, type: nil) @@ -10647,8 +10743,10 @@ public func snapshot(workspace: OptionalConfigValue = .fastlaneDefault( concurrentSimulatorsArg, disableSlideToTypeArg, clonedSourcePackagesPathArg, + packageCachePathArg, skipPackageDependenciesResolutionArg, disablePackageAutomaticUpdatesArg, + skipPackageRepositoryFetchesArg, packageAuthorizationProviderArg, testplanArg, onlyTestingArg, @@ -11224,6 +11322,7 @@ public func supply(packageName: String, - format: Format code when mode is :autocorrect - noCache: Ignore the cache when mode is :autocorrect or :lint - compilerLogPath: Compiler log path when mode is :analyze + - progress: Show a live-updating progress bar instead of each file being processed */ public func swiftlint(mode: String = "lint", path: OptionalConfigValue = .fastlaneDefault(nil), @@ -11238,7 +11337,8 @@ public func swiftlint(mode: String = "lint", executable: OptionalConfigValue = .fastlaneDefault(nil), format: OptionalConfigValue = .fastlaneDefault(false), noCache: OptionalConfigValue = .fastlaneDefault(false), - compilerLogPath: OptionalConfigValue = .fastlaneDefault(nil)) + compilerLogPath: OptionalConfigValue = .fastlaneDefault(nil), + progress: OptionalConfigValue = .fastlaneDefault(false)) { let modeArg = RubyCommand.Argument(name: "mode", value: mode, type: nil) let pathArg = path.asRubyArgument(name: "path", type: nil) @@ -11254,6 +11354,7 @@ public func swiftlint(mode: String = "lint", let formatArg = format.asRubyArgument(name: "format", type: nil) let noCacheArg = noCache.asRubyArgument(name: "no_cache", type: nil) let compilerLogPathArg = compilerLogPath.asRubyArgument(name: "compiler_log_path", type: nil) + let progressArg = progress.asRubyArgument(name: "progress", type: nil) let array: [RubyCommand.Argument?] = [modeArg, pathArg, outputFileArg, @@ -11267,7 +11368,8 @@ public func swiftlint(mode: String = "lint", executableArg, formatArg, noCacheArg, - compilerLogPathArg] + compilerLogPathArg, + progressArg] let args: [RubyCommand.Argument] = array .filter { $0?.value != nil } .compactMap { $0 } @@ -11307,6 +11409,7 @@ public func swiftlint(mode: String = "lint", - s3Region: Name of the S3 region - s3AccessKey: S3 access key - s3SecretAccessKey: S3 secret access key + - s3SessionToken: S3 session token - s3Bucket: Name of the S3 bucket - s3ObjectPrefix: Prefix to be used on all objects uploaded to S3 - s3SkipEncryption: Skip encryption of all objects uploaded to S3. WARNING: only enable this on S3 buckets with sufficiently restricted permissions and server-side encryption enabled. See https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingEncryption.html @@ -11366,6 +11469,7 @@ public func syncCodeSigning(type: String = "development", s3Region: OptionalConfigValue = .fastlaneDefault(nil), s3AccessKey: OptionalConfigValue = .fastlaneDefault(nil), s3SecretAccessKey: OptionalConfigValue = .fastlaneDefault(nil), + s3SessionToken: OptionalConfigValue = .fastlaneDefault(nil), s3Bucket: OptionalConfigValue = .fastlaneDefault(nil), s3ObjectPrefix: OptionalConfigValue = .fastlaneDefault(nil), s3SkipEncryption: OptionalConfigValue = .fastlaneDefault(false), @@ -11423,6 +11527,7 @@ public func syncCodeSigning(type: String = "development", let s3RegionArg = s3Region.asRubyArgument(name: "s3_region", type: nil) let s3AccessKeyArg = s3AccessKey.asRubyArgument(name: "s3_access_key", type: nil) let s3SecretAccessKeyArg = s3SecretAccessKey.asRubyArgument(name: "s3_secret_access_key", type: nil) + let s3SessionTokenArg = s3SessionToken.asRubyArgument(name: "s3_session_token", type: nil) let s3BucketArg = s3Bucket.asRubyArgument(name: "s3_bucket", type: nil) let s3ObjectPrefixArg = s3ObjectPrefix.asRubyArgument(name: "s3_object_prefix", type: nil) let s3SkipEncryptionArg = s3SkipEncryption.asRubyArgument(name: "s3_skip_encryption", type: nil) @@ -11479,6 +11584,7 @@ public func syncCodeSigning(type: String = "development", s3RegionArg, s3AccessKeyArg, s3SecretAccessKeyArg, + s3SessionTokenArg, s3BucketArg, s3ObjectPrefixArg, s3SkipEncryptionArg, @@ -11659,6 +11765,7 @@ public func testfairy(apiKey: String, - teamName: The name of your App Store Connect team if you're in multiple teams - devPortalTeamId: The short ID of your team in the developer portal, if you're in multiple teams. Different from your iTC team ID! - itcProvider: The provider short name to be used with the iTMSTransporter to identify your team. This value will override the automatically detected provider short name. To get provider short name run `pathToXcode.app/Contents/Applications/Application\ Loader.app/Contents/itms/bin/iTMSTransporter -m provider -u 'USERNAME' -p 'PASSWORD' -account_type itunes_connect -v off`. The short names of providers should be listed in the second column + - providerPublicId: The provider public ID to be used with altool (--provider-public-id). This value will override the automatically detected provider value for altool uploads. Required after Xcode 26 when your account is associated with multiple providers and using username/app-password authentication - waitProcessingInterval: Interval in seconds to wait for App Store Connect processing - waitProcessingTimeoutDuration: Timeout duration in seconds to wait for App Store Connect processing. If set, after exceeding timeout duration, this will `force stop` to wait for App Store Connect processing and exit with exception - waitForUploadedBuild: **DEPRECATED!** No longer needed with the transition over to the App Store Connect API - Use version info from uploaded ipa file to determine what build to use for distribution. If set to false, latest processing or any latest build will be used @@ -11702,6 +11809,7 @@ public func testflight(apiKeyPath: OptionalConfigValue = .fastlaneDefau teamName: OptionalConfigValue = .fastlaneDefault(nil), devPortalTeamId: OptionalConfigValue = .fastlaneDefault(nil), itcProvider: OptionalConfigValue = .fastlaneDefault(nil), + providerPublicId: OptionalConfigValue = .fastlaneDefault(nil), waitProcessingInterval: Int = 30, waitProcessingTimeoutDuration: OptionalConfigValue = .fastlaneDefault(nil), waitForUploadedBuild: OptionalConfigValue = .fastlaneDefault(false), @@ -11742,6 +11850,7 @@ public func testflight(apiKeyPath: OptionalConfigValue = .fastlaneDefau let teamNameArg = teamName.asRubyArgument(name: "team_name", type: nil) let devPortalTeamIdArg = devPortalTeamId.asRubyArgument(name: "dev_portal_team_id", type: nil) let itcProviderArg = itcProvider.asRubyArgument(name: "itc_provider", type: nil) + let providerPublicIdArg = providerPublicId.asRubyArgument(name: "provider_public_id", type: nil) let waitProcessingIntervalArg = RubyCommand.Argument(name: "wait_processing_interval", value: waitProcessingInterval, type: nil) let waitProcessingTimeoutDurationArg = waitProcessingTimeoutDuration.asRubyArgument(name: "wait_processing_timeout_duration", type: nil) let waitForUploadedBuildArg = waitForUploadedBuild.asRubyArgument(name: "wait_for_uploaded_build", type: nil) @@ -11781,6 +11890,7 @@ public func testflight(apiKeyPath: OptionalConfigValue = .fastlaneDefau teamNameArg, devPortalTeamIdArg, itcProviderArg, + providerPublicIdArg, waitProcessingIntervalArg, waitProcessingTimeoutDurationArg, waitForUploadedBuildArg, @@ -12549,6 +12659,9 @@ public func uploadSymbolsToSentry(apiHost: String = "https://app.getsentry.com/a - useLiveVersion: Force usage of live version rather than edit version - metadataPath: Path to the folder containing the metadata files - screenshotsPath: Path to the folder containing the screenshots + - appPreviewsPath: Path to the folder containing localized App Preview videos + - previewFrameTimeCode: Time code for the App Preview still frame written as hour:minute:second:centisecond (e.g. 00:00:00:01) + - overwritePreviewVideos: Clear all previously uploaded App Preview videos before uploading the new ones - skipBinaryUpload: Skip uploading an ipa or pkg to App Store Connect - skipScreenshots: Don't upload the screenshots - skipMetadata: Don't upload the metadata (e.g. title, description). This will still upload screenshots @@ -12573,6 +12686,7 @@ public func uploadSymbolsToSentry(apiHost: String = "https://app.getsentry.com/a - devPortalTeamId: The short ID of your Developer Portal team, if you're in multiple teams. Different from your iTC team ID! - devPortalTeamName: The name of your Developer Portal team if you're in multiple teams - itcProvider: The provider short name to be used with the iTMSTransporter to identify your team. This value will override the automatically detected provider short name. To get provider short name run `pathToXcode.app/Contents/Applications/Application\ Loader.app/Contents/itms/bin/iTMSTransporter -m provider -u 'USERNAME' -p 'PASSWORD' -account_type itunes_connect -v off`. The short names of providers should be listed in the second column + - providerPublicId: The provider public ID to be used with altool (--provider-public-id). This value will override the automatically detected provider value for altool uploads. Required after Xcode 26 when your account is associated with multiple providers and using username/app-password authentication - runPrecheckBeforeSubmit: Run precheck before submitting to app review - precheckDefaultRuleLevel: The default precheck rule level unless otherwise configured - individualMetadataItems: **DEPRECATED!** Removed after the migration to the new App Store Connect API in June 2020 - An array of localized metadata items to upload individually by language so that errors can be identified. E.g. ['name', 'keywords', 'description']. Note: slow @@ -12608,7 +12722,7 @@ public func uploadSymbolsToSentry(apiHost: String = "https://app.getsentry.com/a If you don't want to verify an HTML preview for App Store builds, use the `:force` option. This is useful when running _fastlane_ on your Continuous Integration server: `_upload_to_app_store_(force: true)` - If your account is on multiple teams and you need to tell the `iTMSTransporter` which 'provider' to use, you can set the `:itc_provider` option to pass this info. + If your account is on multiple teams and you need to tell the transporter which provider to use, you can set `:itc_provider` or `:provider_public_id`. */ public func uploadToAppStore(apiKeyPath: OptionalConfigValue = .fastlaneDefault(nil), apiKey: OptionalConfigValue<[String: Any]?> = .fastlaneDefault(nil), @@ -12623,6 +12737,9 @@ public func uploadToAppStore(apiKeyPath: OptionalConfigValue = .fastlan useLiveVersion: OptionalConfigValue = .fastlaneDefault(false), metadataPath: OptionalConfigValue = .fastlaneDefault(nil), screenshotsPath: OptionalConfigValue = .fastlaneDefault(nil), + appPreviewsPath: OptionalConfigValue = .fastlaneDefault(nil), + previewFrameTimeCode: String = "00:00:05:00", + overwritePreviewVideos: OptionalConfigValue = .fastlaneDefault(false), skipBinaryUpload: OptionalConfigValue = .fastlaneDefault(false), skipScreenshots: OptionalConfigValue = .fastlaneDefault(false), skipMetadata: OptionalConfigValue = .fastlaneDefault(false), @@ -12647,6 +12764,7 @@ public func uploadToAppStore(apiKeyPath: OptionalConfigValue = .fastlan devPortalTeamId: OptionalConfigValue = .fastlaneDefault(nil), devPortalTeamName: OptionalConfigValue = .fastlaneDefault(nil), itcProvider: OptionalConfigValue = .fastlaneDefault(nil), + providerPublicId: OptionalConfigValue = .fastlaneDefault(nil), runPrecheckBeforeSubmit: OptionalConfigValue = .fastlaneDefault(true), precheckDefaultRuleLevel: String = "warn", individualMetadataItems: OptionalConfigValue<[String]?> = .fastlaneDefault(nil), @@ -12690,6 +12808,9 @@ public func uploadToAppStore(apiKeyPath: OptionalConfigValue = .fastlan let useLiveVersionArg = useLiveVersion.asRubyArgument(name: "use_live_version", type: nil) let metadataPathArg = metadataPath.asRubyArgument(name: "metadata_path", type: nil) let screenshotsPathArg = screenshotsPath.asRubyArgument(name: "screenshots_path", type: nil) + let appPreviewsPathArg = appPreviewsPath.asRubyArgument(name: "app_previews_path", type: nil) + let previewFrameTimeCodeArg = RubyCommand.Argument(name: "preview_frame_time_code", value: previewFrameTimeCode, type: nil) + let overwritePreviewVideosArg = overwritePreviewVideos.asRubyArgument(name: "overwrite_preview_videos", type: nil) let skipBinaryUploadArg = skipBinaryUpload.asRubyArgument(name: "skip_binary_upload", type: nil) let skipScreenshotsArg = skipScreenshots.asRubyArgument(name: "skip_screenshots", type: nil) let skipMetadataArg = skipMetadata.asRubyArgument(name: "skip_metadata", type: nil) @@ -12714,6 +12835,7 @@ public func uploadToAppStore(apiKeyPath: OptionalConfigValue = .fastlan let devPortalTeamIdArg = devPortalTeamId.asRubyArgument(name: "dev_portal_team_id", type: nil) let devPortalTeamNameArg = devPortalTeamName.asRubyArgument(name: "dev_portal_team_name", type: nil) let itcProviderArg = itcProvider.asRubyArgument(name: "itc_provider", type: nil) + let providerPublicIdArg = providerPublicId.asRubyArgument(name: "provider_public_id", type: nil) let runPrecheckBeforeSubmitArg = runPrecheckBeforeSubmit.asRubyArgument(name: "run_precheck_before_submit", type: nil) let precheckDefaultRuleLevelArg = RubyCommand.Argument(name: "precheck_default_rule_level", value: precheckDefaultRuleLevel, type: nil) let individualMetadataItemsArg = individualMetadataItems.asRubyArgument(name: "individual_metadata_items", type: nil) @@ -12756,6 +12878,9 @@ public func uploadToAppStore(apiKeyPath: OptionalConfigValue = .fastlan useLiveVersionArg, metadataPathArg, screenshotsPathArg, + appPreviewsPathArg, + previewFrameTimeCodeArg, + overwritePreviewVideosArg, skipBinaryUploadArg, skipScreenshotsArg, skipMetadataArg, @@ -12780,6 +12905,7 @@ public func uploadToAppStore(apiKeyPath: OptionalConfigValue = .fastlan devPortalTeamIdArg, devPortalTeamNameArg, itcProviderArg, + providerPublicIdArg, runPrecheckBeforeSubmitArg, precheckDefaultRuleLevelArg, individualMetadataItemsArg, @@ -13082,6 +13208,7 @@ public func uploadToPlayStoreInternalAppSharing(packageName: String, - teamName: The name of your App Store Connect team if you're in multiple teams - devPortalTeamId: The short ID of your team in the developer portal, if you're in multiple teams. Different from your iTC team ID! - itcProvider: The provider short name to be used with the iTMSTransporter to identify your team. This value will override the automatically detected provider short name. To get provider short name run `pathToXcode.app/Contents/Applications/Application\ Loader.app/Contents/itms/bin/iTMSTransporter -m provider -u 'USERNAME' -p 'PASSWORD' -account_type itunes_connect -v off`. The short names of providers should be listed in the second column + - providerPublicId: The provider public ID to be used with altool (--provider-public-id). This value will override the automatically detected provider value for altool uploads. Required after Xcode 26 when your account is associated with multiple providers and using username/app-password authentication - waitProcessingInterval: Interval in seconds to wait for App Store Connect processing - waitProcessingTimeoutDuration: Timeout duration in seconds to wait for App Store Connect processing. If set, after exceeding timeout duration, this will `force stop` to wait for App Store Connect processing and exit with exception - waitForUploadedBuild: **DEPRECATED!** No longer needed with the transition over to the App Store Connect API - Use version info from uploaded ipa file to determine what build to use for distribution. If set to false, latest processing or any latest build will be used @@ -13125,6 +13252,7 @@ public func uploadToTestflight(apiKeyPath: OptionalConfigValue = .fastl teamName: OptionalConfigValue = .fastlaneDefault(nil), devPortalTeamId: OptionalConfigValue = .fastlaneDefault(nil), itcProvider: OptionalConfigValue = .fastlaneDefault(nil), + providerPublicId: OptionalConfigValue = .fastlaneDefault(nil), waitProcessingInterval: Int = 30, waitProcessingTimeoutDuration: OptionalConfigValue = .fastlaneDefault(nil), waitForUploadedBuild: OptionalConfigValue = .fastlaneDefault(false), @@ -13165,6 +13293,7 @@ public func uploadToTestflight(apiKeyPath: OptionalConfigValue = .fastl let teamNameArg = teamName.asRubyArgument(name: "team_name", type: nil) let devPortalTeamIdArg = devPortalTeamId.asRubyArgument(name: "dev_portal_team_id", type: nil) let itcProviderArg = itcProvider.asRubyArgument(name: "itc_provider", type: nil) + let providerPublicIdArg = providerPublicId.asRubyArgument(name: "provider_public_id", type: nil) let waitProcessingIntervalArg = RubyCommand.Argument(name: "wait_processing_interval", value: waitProcessingInterval, type: nil) let waitProcessingTimeoutDurationArg = waitProcessingTimeoutDuration.asRubyArgument(name: "wait_processing_timeout_duration", type: nil) let waitForUploadedBuildArg = waitForUploadedBuild.asRubyArgument(name: "wait_for_uploaded_build", type: nil) @@ -13204,6 +13333,7 @@ public func uploadToTestflight(apiKeyPath: OptionalConfigValue = .fastl teamNameArg, devPortalTeamIdArg, itcProviderArg, + providerPublicIdArg, waitProcessingIntervalArg, waitProcessingTimeoutDurationArg, waitForUploadedBuildArg, @@ -13573,7 +13703,11 @@ public func xcodebuild() { - configuration: The configuration used when building the app. Defaults to 'Release' - sourceDirectory: The path to project's root directory - derivedDataPath: The directory where build products and other derived data will go + - xccovFileDirectPath: The path or array of paths to the xccoverage/xccovreport/xcresult files to parse to generate code coverage - outputDirectory: The directory in which all reports will be stored + - clonedSourcePackagesPath: Sets a custom path for Swift Package Manager dependencies + - useSystemScm: Lets xcodebuild use system's scm configuration + - isSwiftPackage: Enables generating coverage reports for Package.swift derived projects - htmlReport: Produce an HTML report - markdownReport: Produce a Markdown report - jsonReport: Produce a JSON report @@ -13585,6 +13719,7 @@ public func xcodebuild() { - slackMessage: The message which is published together with a successful report - ignoreFilePath: Relative or absolute path to the file containing the list of ignored files - includeTestTargets: Enables coverage reports for .xctest targets + - includeZeroTargets: Final report will include target even if the coverage is 0% - excludeTargets: Comma separated list of targets to exclude from coverage report - includeTargets: Comma separated list of targets to include in coverage report. If specified then exlude_targets will be ignored - onlyProjectTargets: Display the coverage only for main project targets (e.g. skip Pods targets) @@ -13597,7 +13732,7 @@ public func xcodebuild() { - legacySupport: Whether xcov should parse a xccoverage file instead on xccovreport Create nice code coverage reports and post coverage summaries on Slack *(xcov gem is required)*. - More information: [https://github.com/nakiostudio/xcov](https://github.com/nakiostudio/xcov). + More information: [https://github.com/fastlane-community/xcov](https://github.com/fastlane-community/xcov). */ public func xcov(workspace: OptionalConfigValue = .fastlaneDefault(nil), project: OptionalConfigValue = .fastlaneDefault(nil), @@ -13605,7 +13740,11 @@ public func xcov(workspace: OptionalConfigValue = .fastlaneDefault(nil) configuration: OptionalConfigValue = .fastlaneDefault(nil), sourceDirectory: OptionalConfigValue = .fastlaneDefault(nil), derivedDataPath: OptionalConfigValue = .fastlaneDefault(nil), + xccovFileDirectPath: OptionalConfigValue<[String]?> = .fastlaneDefault(nil), outputDirectory: String = "./xcov_report", + clonedSourcePackagesPath: OptionalConfigValue = .fastlaneDefault(nil), + useSystemScm: OptionalConfigValue = .fastlaneDefault(false), + isSwiftPackage: OptionalConfigValue = .fastlaneDefault(false), htmlReport: OptionalConfigValue = .fastlaneDefault(true), markdownReport: OptionalConfigValue = .fastlaneDefault(false), jsonReport: OptionalConfigValue = .fastlaneDefault(false), @@ -13617,6 +13756,7 @@ public func xcov(workspace: OptionalConfigValue = .fastlaneDefault(nil) slackMessage: String = "Your *xcov* coverage report", ignoreFilePath: String = "./.xcovignore", includeTestTargets: OptionalConfigValue = .fastlaneDefault(false), + includeZeroTargets: OptionalConfigValue = .fastlaneDefault(true), excludeTargets: OptionalConfigValue = .fastlaneDefault(nil), includeTargets: OptionalConfigValue = .fastlaneDefault(nil), onlyProjectTargets: OptionalConfigValue = .fastlaneDefault(false), @@ -13634,7 +13774,11 @@ public func xcov(workspace: OptionalConfigValue = .fastlaneDefault(nil) let configurationArg = configuration.asRubyArgument(name: "configuration", type: nil) let sourceDirectoryArg = sourceDirectory.asRubyArgument(name: "source_directory", type: nil) let derivedDataPathArg = derivedDataPath.asRubyArgument(name: "derived_data_path", type: nil) + let xccovFileDirectPathArg = xccovFileDirectPath.asRubyArgument(name: "xccov_file_direct_path", type: nil) let outputDirectoryArg = RubyCommand.Argument(name: "output_directory", value: outputDirectory, type: nil) + let clonedSourcePackagesPathArg = clonedSourcePackagesPath.asRubyArgument(name: "cloned_source_packages_path", type: nil) + let useSystemScmArg = useSystemScm.asRubyArgument(name: "use_system_scm", type: nil) + let isSwiftPackageArg = isSwiftPackage.asRubyArgument(name: "is_swift_package", type: nil) let htmlReportArg = htmlReport.asRubyArgument(name: "html_report", type: nil) let markdownReportArg = markdownReport.asRubyArgument(name: "markdown_report", type: nil) let jsonReportArg = jsonReport.asRubyArgument(name: "json_report", type: nil) @@ -13646,6 +13790,7 @@ public func xcov(workspace: OptionalConfigValue = .fastlaneDefault(nil) let slackMessageArg = RubyCommand.Argument(name: "slack_message", value: slackMessage, type: nil) let ignoreFilePathArg = RubyCommand.Argument(name: "ignore_file_path", value: ignoreFilePath, type: nil) let includeTestTargetsArg = includeTestTargets.asRubyArgument(name: "include_test_targets", type: nil) + let includeZeroTargetsArg = includeZeroTargets.asRubyArgument(name: "include_zero_targets", type: nil) let excludeTargetsArg = excludeTargets.asRubyArgument(name: "exclude_targets", type: nil) let includeTargetsArg = includeTargets.asRubyArgument(name: "include_targets", type: nil) let onlyProjectTargetsArg = onlyProjectTargets.asRubyArgument(name: "only_project_targets", type: nil) @@ -13662,7 +13807,11 @@ public func xcov(workspace: OptionalConfigValue = .fastlaneDefault(nil) configurationArg, sourceDirectoryArg, derivedDataPathArg, + xccovFileDirectPathArg, outputDirectoryArg, + clonedSourcePackagesPathArg, + useSystemScmArg, + isSwiftPackageArg, htmlReportArg, markdownReportArg, jsonReportArg, @@ -13674,6 +13823,7 @@ public func xcov(workspace: OptionalConfigValue = .fastlaneDefault(nil) slackMessageArg, ignoreFilePathArg, includeTestTargetsArg, + includeZeroTargetsArg, excludeTargetsArg, includeTargetsArg, onlyProjectTargetsArg, @@ -13774,7 +13924,7 @@ public func xcversion(version: String) { return runner.executeCommand(command) } -// These are all the parsing functions needed to transform our data into the expected types +/// These are all the parsing functions needed to transform our data into the expected types func parseArray(fromString: String, function: String = #function) -> [String] { verbose(message: "parsing an Array from data: \(fromString), from function: \(function)") let potentialArray: String @@ -13783,8 +13933,7 @@ func parseArray(fromString: String, function: String = #function) -> [String] { } else { potentialArray = fromString } - let array: [String] = try! JSONSerialization.jsonObject(with: potentialArray.data(using: .utf8)!, options: []) as! [String] - return array + return try! JSONSerialization.jsonObject(with: potentialArray.data(using: .utf8)!, options: []) as! [String] } func parseDictionary(fromString: String, function: String = #function) -> [String: String] { @@ -13804,8 +13953,7 @@ func parseDictionaryHelper(fromString: String, function: String = #function) -> } else { potentialDictionary = fromString } - let dictionary: [String: Any] = try! JSONSerialization.jsonObject(with: potentialDictionary.data(using: .utf8)!, options: []) as! [String: Any] - return dictionary + return try! JSONSerialization.jsonObject(with: potentialDictionary.data(using: .utf8)!, options: []) as! [String: Any] } func parseBool(fromString: String, function: String = #function) -> Bool { @@ -13828,4 +13976,4 @@ public let snapshotfile: Snapshotfile = .init() // Please don't remove the lines below // They are used to detect outdated files -// FastlaneRunnerAPIVersion [0.9.191] +// FastlaneRunnerAPIVersion [0.9.198] diff --git a/fastlane/swift/GymfileProtocol.swift b/fastlane/swift/GymfileProtocol.swift index 0a8e02683..fe4614bf4 100644 --- a/fastlane/swift/GymfileProtocol.swift +++ b/fastlane/swift/GymfileProtocol.swift @@ -1,5 +1,5 @@ // GymfileProtocol.swift -// Copyright (c) 2025 FastlaneTools +// Copyright (c) 2026 FastlaneTools public protocol GymfileProtocol: AnyObject { /// Path to the workspace file @@ -20,6 +20,9 @@ public protocol GymfileProtocol: AnyObject { /// The name of the resulting ipa file var outputName: String? { get } + /// App name to use in logfile name + var appName: String? { get } + /// The configuration to use when building the app. Defaults to 'Release' var configuration: String? { get } @@ -143,73 +146,250 @@ public protocol GymfileProtocol: AnyObject { /// Sets a custom path for Swift Package Manager dependencies var clonedSourcePackagesPath: String? { get } + /// Sets a custom package cache path for Swift Package Manager dependencies + var packageCachePath: String? { get } + /// Skips resolution of Swift Package Manager dependencies var skipPackageDependenciesResolution: Bool { get } - /// Prevents packages from automatically being resolved to versions other than those recorded in the `Package.resolved` file + /// Prevents packages from automatically being resolved to versions other than those recorded in the `Package.resolved` file. This translates in the option `-disableAutomaticPackageResolution` being passed to xcodebuild var disablePackageAutomaticUpdates: Bool { get } + /// Skips updating package dependencies from their remote. This translates in the option `-skipPackageUpdates` being passed to xcodebuild + var skipPackageRepositoryFetches: Bool { get } + /// Lets xcodebuild use system's scm configuration var useSystemScm: Bool { get } /// Lets xcodebuild use a specified package authorization provider (keychain|netrc) var packageAuthorizationProvider: String? { get } + + /// Generate AppStoreInfo.plist using swinfo for app-store exports + var generateAppstoreInfo: Bool { get } } public extension GymfileProtocol { - var workspace: String? { return nil } - var project: String? { return nil } - var scheme: String? { return nil } - var clean: Bool { return false } - var outputDirectory: String { return "." } - var outputName: String? { return nil } - var configuration: String? { return nil } - var silent: Bool { return false } - var codesigningIdentity: String? { return nil } - var skipPackageIpa: Bool { return false } - var skipPackagePkg: Bool { return false } - var includeSymbols: Bool? { return nil } - var includeBitcode: Bool? { return nil } - var exportMethod: String? { return nil } - var exportOptions: [String: Any]? { return nil } - var exportXcargs: String? { return nil } - var skipBuildArchive: Bool? { return nil } - var skipArchive: Bool? { return nil } - var skipCodesigning: Bool? { return nil } - var catalystPlatform: String? { return nil } - var installerCertName: String? { return nil } - var buildPath: String? { return nil } - var archivePath: String? { return nil } - var derivedDataPath: String? { return nil } - var resultBundle: Bool { return false } - var resultBundlePath: String? { return nil } - var buildlogPath: String { return "~/Library/Logs/gym" } - var sdk: String? { return nil } - var toolchain: String? { return nil } - var destination: String? { return nil } - var exportTeamId: String? { return nil } - var xcargs: String? { return nil } - var xcconfig: String? { return nil } - var suppressXcodeOutput: Bool? { return nil } - var xcodebuildFormatter: String { return "xcbeautify" } - var buildTimingSummary: Bool { return false } - var disableXcpretty: Bool? { return nil } - var xcprettyTestFormat: Bool? { return nil } - var xcprettyFormatter: String? { return nil } - var xcprettyReportJunit: String? { return nil } - var xcprettyReportHtml: String? { return nil } - var xcprettyReportJson: String? { return nil } - var xcprettyUtf: Bool? { return nil } - var analyzeBuildTime: Bool? { return nil } - var skipProfileDetection: Bool { return false } - var xcodebuildCommand: String { return "xcodebuild" } - var clonedSourcePackagesPath: String? { return nil } - var skipPackageDependenciesResolution: Bool { return false } - var disablePackageAutomaticUpdates: Bool { return false } - var useSystemScm: Bool { return false } - var packageAuthorizationProvider: String? { return nil } + var workspace: String? { + return nil + } + + var project: String? { + return nil + } + + var scheme: String? { + return nil + } + + var clean: Bool { + return false + } + + var outputDirectory: String { + return "." + } + + var outputName: String? { + return nil + } + + var appName: String? { + return nil + } + + var configuration: String? { + return nil + } + + var silent: Bool { + return false + } + + var codesigningIdentity: String? { + return nil + } + + var skipPackageIpa: Bool { + return false + } + + var skipPackagePkg: Bool { + return false + } + + var includeSymbols: Bool? { + return nil + } + + var includeBitcode: Bool? { + return nil + } + + var exportMethod: String? { + return nil + } + + var exportOptions: [String: Any]? { + return nil + } + + var exportXcargs: String? { + return nil + } + + var skipBuildArchive: Bool? { + return nil + } + + var skipArchive: Bool? { + return nil + } + + var skipCodesigning: Bool? { + return nil + } + + var catalystPlatform: String? { + return nil + } + + var installerCertName: String? { + return nil + } + + var buildPath: String? { + return nil + } + + var archivePath: String? { + return nil + } + + var derivedDataPath: String? { + return nil + } + + var resultBundle: Bool { + return false + } + + var resultBundlePath: String? { + return nil + } + + var buildlogPath: String { + return "~/Library/Logs/gym" + } + + var sdk: String? { + return nil + } + + var toolchain: String? { + return nil + } + + var destination: String? { + return nil + } + + var exportTeamId: String? { + return nil + } + + var xcargs: String? { + return nil + } + + var xcconfig: String? { + return nil + } + + var suppressXcodeOutput: Bool? { + return nil + } + + var xcodebuildFormatter: String { + return "xcbeautify" + } + + var buildTimingSummary: Bool { + return false + } + + var disableXcpretty: Bool? { + return nil + } + + var xcprettyTestFormat: Bool? { + return nil + } + + var xcprettyFormatter: String? { + return nil + } + + var xcprettyReportJunit: String? { + return nil + } + + var xcprettyReportHtml: String? { + return nil + } + + var xcprettyReportJson: String? { + return nil + } + + var xcprettyUtf: Bool? { + return nil + } + + var analyzeBuildTime: Bool? { + return nil + } + + var skipProfileDetection: Bool { + return false + } + + var xcodebuildCommand: String { + return "xcodebuild" + } + + var clonedSourcePackagesPath: String? { + return nil + } + + var packageCachePath: String? { + return nil + } + + var skipPackageDependenciesResolution: Bool { + return false + } + + var disablePackageAutomaticUpdates: Bool { + return false + } + + var skipPackageRepositoryFetches: Bool { + return false + } + + var useSystemScm: Bool { + return false + } + + var packageAuthorizationProvider: String? { + return nil + } + + var generateAppstoreInfo: Bool { + return false + } } // Please don't remove the lines below // They are used to detect outdated files -// FastlaneRunnerAPIVersion [0.9.141] +// FastlaneRunnerAPIVersion [0.9.148] diff --git a/fastlane/swift/LaneFileProtocol.swift b/fastlane/swift/LaneFileProtocol.swift index 702fa286b..e40d4b231 100644 --- a/fastlane/swift/LaneFileProtocol.swift +++ b/fastlane/swift/LaneFileProtocol.swift @@ -1,5 +1,5 @@ // LaneFileProtocol.swift -// Copyright (c) 2025 FastlaneTools +// Copyright (c) 2026 FastlaneTools // // ** NOTE ** @@ -21,7 +21,9 @@ public protocol LaneFileProtocol: AnyObject { } public extension LaneFileProtocol { - var fastlaneVersion: String { return "" } // Defaults to "" because that means any is fine + var fastlaneVersion: String { + return "" + } // Defaults to "" because that means any is fine func beforeAll(with _: String) {} // No-op by default func afterAll(with _: String) {} // No-op by default func recordLaneDescriptions() {} // No-op by default diff --git a/fastlane/swift/MatchfileProtocol.swift b/fastlane/swift/MatchfileProtocol.swift index 66c8eab87..e0c3be080 100644 --- a/fastlane/swift/MatchfileProtocol.swift +++ b/fastlane/swift/MatchfileProtocol.swift @@ -1,5 +1,5 @@ // MatchfileProtocol.swift -// Copyright (c) 2025 FastlaneTools +// Copyright (c) 2026 FastlaneTools public protocol MatchfileProtocol: AnyObject { /// Define the profile type, can be appstore, adhoc, development, enterprise, developer_id, mac_installer_distribution, developer_id_installer @@ -86,6 +86,9 @@ public protocol MatchfileProtocol: AnyObject { /// S3 secret access key var s3SecretAccessKey: String? { get } + /// S3 session token + var s3SessionToken: String? { get } + /// Name of the S3 bucket var s3Bucket: String? { get } @@ -172,64 +175,235 @@ public protocol MatchfileProtocol: AnyObject { } public extension MatchfileProtocol { - var type: String { return "development" } - var additionalCertTypes: [String]? { return nil } - var readonly: Bool { return false } - var generateAppleCerts: Bool { return true } - var skipProvisioningProfiles: Bool { return false } - var appIdentifier: [String] { return [] } - var apiKeyPath: String? { return nil } - var apiKey: [String: Any]? { return nil } - var username: String? { return nil } - var teamId: String? { return nil } - var teamName: String? { return nil } - var storageMode: String { return "git" } - var gitUrl: String { return "" } - var gitBranch: String { return "master" } - var gitFullName: String? { return nil } - var gitUserEmail: String? { return nil } - var shallowClone: Bool { return false } - var cloneBranchDirectly: Bool { return false } - var gitBasicAuthorization: String? { return nil } - var gitBearerAuthorization: String? { return nil } - var gitPrivateKey: String? { return nil } - var googleCloudBucketName: String? { return nil } - var googleCloudKeysFile: String? { return nil } - var googleCloudProjectId: String? { return nil } - var skipGoogleCloudAccountConfirmation: Bool { return false } - var s3Region: String? { return nil } - var s3AccessKey: String? { return nil } - var s3SecretAccessKey: String? { return nil } - var s3Bucket: String? { return nil } - var s3ObjectPrefix: String? { return nil } - var s3SkipEncryption: Bool { return false } - var gitlabProject: String? { return nil } - var gitlabHost: String { return "https://gitlab.com" } - var jobToken: String? { return nil } - var privateToken: String? { return nil } - var keychainName: String { return "login.keychain" } - var keychainPassword: String? { return nil } - var force: Bool { return false } - var forceForNewDevices: Bool { return false } - var includeMacInProfiles: Bool { return false } - var includeAllCertificates: Bool { return false } - var certificateId: String? { return nil } - var forceForNewCertificates: Bool { return false } - var skipConfirmation: Bool { return false } - var safeRemoveCerts: Bool { return false } - var skipDocs: Bool { return false } - var platform: String { return "ios" } - var deriveCatalystAppIdentifier: Bool { return false } - var templateName: String? { return nil } - var profileName: String? { return nil } - var failOnNameTaken: Bool { return false } - var skipCertificateMatching: Bool { return false } - var outputPath: String? { return nil } - var skipSetPartitionList: Bool { return false } - var forceLegacyEncryption: Bool { return false } - var verbose: Bool { return false } + var type: String { + return "development" + } + + var additionalCertTypes: [String]? { + return nil + } + + var readonly: Bool { + return false + } + + var generateAppleCerts: Bool { + return true + } + + var skipProvisioningProfiles: Bool { + return false + } + + var appIdentifier: [String] { + return [] + } + + var apiKeyPath: String? { + return nil + } + + var apiKey: [String: Any]? { + return nil + } + + var username: String? { + return nil + } + + var teamId: String? { + return nil + } + + var teamName: String? { + return nil + } + + var storageMode: String { + return "git" + } + + var gitUrl: String { + return "" + } + + var gitBranch: String { + return "master" + } + + var gitFullName: String? { + return nil + } + + var gitUserEmail: String? { + return nil + } + + var shallowClone: Bool { + return false + } + + var cloneBranchDirectly: Bool { + return false + } + + var gitBasicAuthorization: String? { + return nil + } + + var gitBearerAuthorization: String? { + return nil + } + + var gitPrivateKey: String? { + return nil + } + + var googleCloudBucketName: String? { + return nil + } + + var googleCloudKeysFile: String? { + return nil + } + + var googleCloudProjectId: String? { + return nil + } + + var skipGoogleCloudAccountConfirmation: Bool { + return false + } + + var s3Region: String? { + return nil + } + + var s3AccessKey: String? { + return nil + } + + var s3SecretAccessKey: String? { + return nil + } + + var s3SessionToken: String? { + return nil + } + + var s3Bucket: String? { + return nil + } + + var s3ObjectPrefix: String? { + return nil + } + + var s3SkipEncryption: Bool { + return false + } + + var gitlabProject: String? { + return nil + } + + var gitlabHost: String { + return "https://gitlab.com" + } + + var jobToken: String? { + return nil + } + + var privateToken: String? { + return nil + } + + var keychainName: String { + return "login.keychain" + } + + var keychainPassword: String? { + return nil + } + + var force: Bool { + return false + } + + var forceForNewDevices: Bool { + return false + } + + var includeMacInProfiles: Bool { + return false + } + + var includeAllCertificates: Bool { + return false + } + + var certificateId: String? { + return nil + } + + var forceForNewCertificates: Bool { + return false + } + + var skipConfirmation: Bool { + return false + } + + var safeRemoveCerts: Bool { + return false + } + + var skipDocs: Bool { + return false + } + + var platform: String { + return "ios" + } + + var deriveCatalystAppIdentifier: Bool { + return false + } + + var templateName: String? { + return nil + } + + var profileName: String? { + return nil + } + + var failOnNameTaken: Bool { + return false + } + + var skipCertificateMatching: Bool { + return false + } + + var outputPath: String? { + return nil + } + + var skipSetPartitionList: Bool { + return false + } + + var forceLegacyEncryption: Bool { + return false + } + + var verbose: Bool { + return false + } } // Please don't remove the lines below // They are used to detect outdated files -// FastlaneRunnerAPIVersion [0.9.135] +// FastlaneRunnerAPIVersion [0.9.142] diff --git a/fastlane/swift/Plugins.swift b/fastlane/swift/Plugins.swift index d8f81bd31..16b631fae 100644 --- a/fastlane/swift/Plugins.swift +++ b/fastlane/swift/Plugins.swift @@ -1,5 +1,5 @@ // Plugins.swift -// Copyright (c) 2025 FastlaneTools +// Copyright (c) 2026 FastlaneTools // This autogenerated file will be overwritten or replaced when installing/updating plugins or running "fastlane generate_swift" // diff --git a/fastlane/swift/PrecheckfileProtocol.swift b/fastlane/swift/PrecheckfileProtocol.swift index f03fcde92..935c5ca07 100644 --- a/fastlane/swift/PrecheckfileProtocol.swift +++ b/fastlane/swift/PrecheckfileProtocol.swift @@ -1,5 +1,5 @@ // PrecheckfileProtocol.swift -// Copyright (c) 2025 FastlaneTools +// Copyright (c) 2026 FastlaneTools public protocol PrecheckfileProtocol: AnyObject { /// Path to your App Store Connect API Key JSON file (https://docs.fastlane.tools/app-store-connect-api/#using-fastlane-api-key-json-file) @@ -37,19 +37,51 @@ public protocol PrecheckfileProtocol: AnyObject { } public extension PrecheckfileProtocol { - var apiKeyPath: String? { return nil } - var apiKey: [String: Any]? { return nil } - var appIdentifier: String { return "" } - var username: String? { return nil } - var teamId: String? { return nil } - var teamName: String? { return nil } - var platform: String { return "ios" } - var defaultRuleLevel: String { return "error" } - var includeInAppPurchases: Bool { return true } - var useLive: Bool { return false } - var freeStuffInIap: String? { return nil } + var apiKeyPath: String? { + return nil + } + + var apiKey: [String: Any]? { + return nil + } + + var appIdentifier: String { + return "" + } + + var username: String? { + return nil + } + + var teamId: String? { + return nil + } + + var teamName: String? { + return nil + } + + var platform: String { + return "ios" + } + + var defaultRuleLevel: String { + return "error" + } + + var includeInAppPurchases: Bool { + return true + } + + var useLive: Bool { + return false + } + + var freeStuffInIap: String? { + return nil + } } // Please don't remove the lines below // They are used to detect outdated files -// FastlaneRunnerAPIVersion [0.9.134] +// FastlaneRunnerAPIVersion [0.9.141] diff --git a/fastlane/swift/RubyCommand.swift b/fastlane/swift/RubyCommand.swift index 9891ce220..5aa35a653 100644 --- a/fastlane/swift/RubyCommand.swift +++ b/fastlane/swift/RubyCommand.swift @@ -1,5 +1,5 @@ // RubyCommand.swift -// Copyright (c) 2025 FastlaneTools +// Copyright (c) 2026 FastlaneTools // // ** NOTE ** @@ -11,7 +11,9 @@ import Foundation struct RubyCommand: RubyCommandable { - var type: CommandType { return .action } + var type: CommandType { + return .action + } struct Argument { enum ArgType { @@ -62,8 +64,7 @@ struct RubyCommand: RubyCommandable { "value": someValue, ] let jsonData = try! JSONSerialization.data(withJSONObject: dictionary, options: []) - let jsonString = String(data: jsonData, encoding: .utf8)! - return jsonString + return String(data: jsonData, encoding: .utf8)! } } else { // Just exclude this arg if it doesn't have a value @@ -146,9 +147,7 @@ struct RubyCommand: RubyCommandable { jsonParts.append(classNameJson) } - let commandJsonString = "{\(jsonParts.joined(separator: ","))}" - - return commandJsonString + return "{\(jsonParts.joined(separator: ","))}" } } diff --git a/fastlane/swift/RubyCommandable.swift b/fastlane/swift/RubyCommandable.swift index ed7250bff..6c6340027 100644 --- a/fastlane/swift/RubyCommandable.swift +++ b/fastlane/swift/RubyCommandable.swift @@ -1,5 +1,5 @@ // RubyCommandable.swift -// Copyright (c) 2025 FastlaneTools +// Copyright (c) 2026 FastlaneTools // // ** NOTE ** diff --git a/fastlane/swift/Runner.swift b/fastlane/swift/Runner.swift index e0757255f..ffb32a3ee 100644 --- a/fastlane/swift/Runner.swift +++ b/fastlane/swift/Runner.swift @@ -1,5 +1,5 @@ // Runner.swift -// Copyright (c) 2025 FastlaneTools +// Copyright (c) 2026 FastlaneTools // // ** NOTE ** @@ -97,7 +97,7 @@ class Runner { } } -// Handle threading stuff +/// Handle threading stuff extension Runner { func startSocketThread(port: UInt32) { let secondsToWait = DispatchTimeInterval.seconds(SocketClient.connectTimeoutSeconds) diff --git a/fastlane/swift/RunnerArgument.swift b/fastlane/swift/RunnerArgument.swift index 0d22986e2..8e0ffe10c 100644 --- a/fastlane/swift/RunnerArgument.swift +++ b/fastlane/swift/RunnerArgument.swift @@ -1,5 +1,5 @@ // RunnerArgument.swift -// Copyright (c) 2025 FastlaneTools +// Copyright (c) 2026 FastlaneTools // // ** NOTE ** diff --git a/fastlane/swift/ScanfileProtocol.swift b/fastlane/swift/ScanfileProtocol.swift index 2e273a272..6b4e8c79b 100644 --- a/fastlane/swift/ScanfileProtocol.swift +++ b/fastlane/swift/ScanfileProtocol.swift @@ -1,5 +1,5 @@ // ScanfileProtocol.swift -// Copyright (c) 2025 FastlaneTools +// Copyright (c) 2026 FastlaneTools public protocol ScanfileProtocol: AnyObject { /// Path to the workspace file @@ -221,12 +221,18 @@ public protocol ScanfileProtocol: AnyObject { /// Sets a custom path for Swift Package Manager dependencies var clonedSourcePackagesPath: String? { get } + /// Sets a custom package cache path for Swift Package Manager dependencies + var packageCachePath: String? { get } + /// Skips resolution of Swift Package Manager dependencies var skipPackageDependenciesResolution: Bool { get } - /// Prevents packages from automatically being resolved to versions other than those recorded in the `Package.resolved` file + /// Prevents packages from automatically being resolved to versions other than those recorded in the `Package.resolved` file. This translates in the option `-disableAutomaticPackageResolution` being passed to xcodebuild var disablePackageAutomaticUpdates: Bool { get } + /// Skips updating package dependencies from their remote. This translates in the option `-skipPackageUpdates` being passed to xcodebuild + var skipPackageRepositoryFetches: Bool { get } + /// Lets xcodebuild use system's scm configuration var useSystemScm: Bool { get } @@ -241,87 +247,331 @@ public protocol ScanfileProtocol: AnyObject { } public extension ScanfileProtocol { - var workspace: String? { return nil } - var project: String? { return nil } - var packagePath: String? { return nil } - var scheme: String? { return nil } - var device: String? { return nil } - var devices: [String]? { return nil } - var skipDetectDevices: Bool { return false } - var ensureDevicesFound: Bool { return false } - var forceQuitSimulator: Bool { return false } - var resetSimulator: Bool { return false } - var disableSlideToType: Bool { return true } - var prelaunchSimulator: Bool? { return nil } - var reinstallApp: Bool { return false } - var appIdentifier: String? { return nil } - var onlyTesting: String? { return nil } - var skipTesting: String? { return nil } - var testplan: String? { return nil } - var onlyTestConfigurations: String? { return nil } - var skipTestConfigurations: String? { return nil } - var xctestrun: String? { return nil } - var toolchain: String? { return nil } - var clean: Bool { return false } - var codeCoverage: Bool? { return nil } - var addressSanitizer: Bool? { return nil } - var threadSanitizer: Bool? { return nil } - var openReport: Bool { return false } - var outputDirectory: String { return "./test_output" } - var outputStyle: String? { return nil } - var outputTypes: String { return "html,junit" } - var outputFiles: String? { return nil } - var buildlogPath: String { return "~/Library/Logs/scan" } - var includeSimulatorLogs: Bool { return false } - var suppressXcodeOutput: Bool? { return nil } - var xcodebuildFormatter: String { return "xcbeautify" } - var outputRemoveRetryAttempts: Bool { return false } - var disableXcpretty: Bool? { return nil } - var formatter: String? { return nil } - var xcprettyFormatter: String? { return nil } - var xcprettyArgs: String? { return nil } - var derivedDataPath: String? { return nil } - var shouldZipBuildProducts: Bool { return false } - var outputXctestrun: Bool { return false } - var resultBundlePath: String? { return nil } - var resultBundle: Bool { return false } - var useClangReportName: Bool { return false } - var parallelTesting: Bool? { return nil } - var concurrentWorkers: Int? { return nil } - var maxConcurrentSimulators: Int? { return nil } - var disableConcurrentTesting: Bool { return false } - var skipBuild: Bool { return false } - var testWithoutBuilding: Bool? { return nil } - var buildForTesting: Bool? { return nil } - var sdk: String? { return nil } - var configuration: String? { return nil } - var xcargs: String? { return nil } - var xcconfig: String? { return nil } - var appName: String? { return nil } - var deploymentTargetVersion: String? { return nil } - var slackUrl: String? { return nil } - var slackChannel: String? { return nil } - var slackMessage: String? { return nil } - var slackUseWebhookConfiguredUsernameAndIcon: Bool { return false } - var slackUsername: String { return "fastlane" } - var slackIconUrl: String { return "https://fastlane.tools/assets/img/fastlane_icon.png" } - var skipSlack: Bool { return false } - var slackOnlyOnFailure: Bool { return false } - var slackDefaultPayloads: [String]? { return nil } - var destination: String? { return nil } - var runRosettaSimulator: Bool { return false } - var catalystPlatform: String? { return nil } - var customReportFileName: String? { return nil } - var xcodebuildCommand: String { return "env NSUnbufferedIO=YES xcodebuild" } - var clonedSourcePackagesPath: String? { return nil } - var skipPackageDependenciesResolution: Bool { return false } - var disablePackageAutomaticUpdates: Bool { return false } - var useSystemScm: Bool { return false } - var numberOfRetries: Int { return 0 } - var failBuild: Bool { return true } - var packageAuthorizationProvider: String? { return nil } + var workspace: String? { + return nil + } + + var project: String? { + return nil + } + + var packagePath: String? { + return nil + } + + var scheme: String? { + return nil + } + + var device: String? { + return nil + } + + var devices: [String]? { + return nil + } + + var skipDetectDevices: Bool { + return false + } + + var ensureDevicesFound: Bool { + return false + } + + var forceQuitSimulator: Bool { + return false + } + + var resetSimulator: Bool { + return false + } + + var disableSlideToType: Bool { + return true + } + + var prelaunchSimulator: Bool? { + return nil + } + + var reinstallApp: Bool { + return false + } + + var appIdentifier: String? { + return nil + } + + var onlyTesting: String? { + return nil + } + + var skipTesting: String? { + return nil + } + + var testplan: String? { + return nil + } + + var onlyTestConfigurations: String? { + return nil + } + + var skipTestConfigurations: String? { + return nil + } + + var xctestrun: String? { + return nil + } + + var toolchain: String? { + return nil + } + + var clean: Bool { + return false + } + + var codeCoverage: Bool? { + return nil + } + + var addressSanitizer: Bool? { + return nil + } + + var threadSanitizer: Bool? { + return nil + } + + var openReport: Bool { + return false + } + + var outputDirectory: String { + return "./test_output" + } + + var outputStyle: String? { + return nil + } + + var outputTypes: String { + return "html,junit" + } + + var outputFiles: String? { + return nil + } + + var buildlogPath: String { + return "~/Library/Logs/scan" + } + + var includeSimulatorLogs: Bool { + return false + } + + var suppressXcodeOutput: Bool? { + return nil + } + + var xcodebuildFormatter: String { + return "xcbeautify" + } + + var outputRemoveRetryAttempts: Bool { + return false + } + + var disableXcpretty: Bool? { + return nil + } + + var formatter: String? { + return nil + } + + var xcprettyFormatter: String? { + return nil + } + + var xcprettyArgs: String? { + return nil + } + + var derivedDataPath: String? { + return nil + } + + var shouldZipBuildProducts: Bool { + return false + } + + var outputXctestrun: Bool { + return false + } + + var resultBundlePath: String? { + return nil + } + + var resultBundle: Bool { + return false + } + + var useClangReportName: Bool { + return false + } + + var parallelTesting: Bool? { + return nil + } + + var concurrentWorkers: Int? { + return nil + } + + var maxConcurrentSimulators: Int? { + return nil + } + + var disableConcurrentTesting: Bool { + return false + } + + var skipBuild: Bool { + return false + } + + var testWithoutBuilding: Bool? { + return nil + } + + var buildForTesting: Bool? { + return nil + } + + var sdk: String? { + return nil + } + + var configuration: String? { + return nil + } + + var xcargs: String? { + return nil + } + + var xcconfig: String? { + return nil + } + + var appName: String? { + return nil + } + + var deploymentTargetVersion: String? { + return nil + } + + var slackUrl: String? { + return nil + } + + var slackChannel: String? { + return nil + } + + var slackMessage: String? { + return nil + } + + var slackUseWebhookConfiguredUsernameAndIcon: Bool { + return false + } + + var slackUsername: String { + return "fastlane" + } + + var slackIconUrl: String { + return "https://fastlane.tools/assets/img/fastlane_icon.png" + } + + var skipSlack: Bool { + return false + } + + var slackOnlyOnFailure: Bool { + return false + } + + var slackDefaultPayloads: [String]? { + return nil + } + + var destination: String? { + return nil + } + + var runRosettaSimulator: Bool { + return false + } + + var catalystPlatform: String? { + return nil + } + + var customReportFileName: String? { + return nil + } + + var xcodebuildCommand: String { + return "env NSUnbufferedIO=YES xcodebuild" + } + + var clonedSourcePackagesPath: String? { + return nil + } + + var packageCachePath: String? { + return nil + } + + var skipPackageDependenciesResolution: Bool { + return false + } + + var disablePackageAutomaticUpdates: Bool { + return false + } + + var skipPackageRepositoryFetches: Bool { + return false + } + + var useSystemScm: Bool { + return false + } + + var numberOfRetries: Int { + return 0 + } + + var failBuild: Bool { + return true + } + + var packageAuthorizationProvider: String? { + return nil + } } // Please don't remove the lines below // They are used to detect outdated files -// FastlaneRunnerAPIVersion [0.9.146] +// FastlaneRunnerAPIVersion [0.9.153] diff --git a/fastlane/swift/ScreengrabfileProtocol.swift b/fastlane/swift/ScreengrabfileProtocol.swift index 1cf1e66ed..31f3b558f 100644 --- a/fastlane/swift/ScreengrabfileProtocol.swift +++ b/fastlane/swift/ScreengrabfileProtocol.swift @@ -1,5 +1,5 @@ // ScreengrabfileProtocol.swift -// Copyright (c) 2025 FastlaneTools +// Copyright (c) 2026 FastlaneTools public protocol ScreengrabfileProtocol: AnyObject { /// Path to the root of your Android SDK installation, e.g. ~/tools/android-sdk-macosx @@ -70,30 +70,95 @@ public protocol ScreengrabfileProtocol: AnyObject { } public extension ScreengrabfileProtocol { - var androidHome: String? { return nil } - var buildToolsVersion: String? { return nil } - var locales: [String] { return ["en-US"] } - var clearPreviousScreenshots: Bool { return false } - var outputDirectory: String { return "fastlane/metadata/android" } - var skipOpenSummary: Bool { return false } - var appPackageName: String { return "" } - var testsPackageName: String? { return nil } - var useTestsInPackages: [String]? { return nil } - var useTestsInClasses: [String]? { return nil } - var launchArguments: [String]? { return nil } - var testInstrumentationRunner: String { return "androidx.test.runner.AndroidJUnitRunner" } - var endingLocale: String { return "en-US" } - var useAdbRoot: Bool { return false } - var appApkPath: String? { return nil } - var testsApkPath: String? { return nil } - var specificDevice: String? { return nil } - var deviceType: String { return "phone" } - var exitOnTestFailure: Bool { return true } - var reinstallApp: Bool { return false } - var useTimestampSuffix: Bool { return true } - var adbHost: String? { return nil } + var androidHome: String? { + return nil + } + + var buildToolsVersion: String? { + return nil + } + + var locales: [String] { + return ["en-US"] + } + + var clearPreviousScreenshots: Bool { + return false + } + + var outputDirectory: String { + return "fastlane/metadata/android" + } + + var skipOpenSummary: Bool { + return false + } + + var appPackageName: String { + return "" + } + + var testsPackageName: String? { + return nil + } + + var useTestsInPackages: [String]? { + return nil + } + + var useTestsInClasses: [String]? { + return nil + } + + var launchArguments: [String]? { + return nil + } + + var testInstrumentationRunner: String { + return "androidx.test.runner.AndroidJUnitRunner" + } + + var endingLocale: String { + return "en-US" + } + + var useAdbRoot: Bool { + return false + } + + var appApkPath: String? { + return nil + } + + var testsApkPath: String? { + return nil + } + + var specificDevice: String? { + return nil + } + + var deviceType: String { + return "phone" + } + + var exitOnTestFailure: Bool { + return true + } + + var reinstallApp: Bool { + return false + } + + var useTimestampSuffix: Bool { + return true + } + + var adbHost: String? { + return nil + } } // Please don't remove the lines below // They are used to detect outdated files -// FastlaneRunnerAPIVersion [0.9.136] +// FastlaneRunnerAPIVersion [0.9.143] diff --git a/fastlane/swift/SnapshotfileProtocol.swift b/fastlane/swift/SnapshotfileProtocol.swift index 1ccd8b518..666e35a35 100644 --- a/fastlane/swift/SnapshotfileProtocol.swift +++ b/fastlane/swift/SnapshotfileProtocol.swift @@ -1,5 +1,5 @@ // SnapshotfileProtocol.swift -// Copyright (c) 2025 FastlaneTools +// Copyright (c) 2026 FastlaneTools public protocol SnapshotfileProtocol: AnyObject { /// Path to the workspace file @@ -119,12 +119,18 @@ public protocol SnapshotfileProtocol: AnyObject { /// Sets a custom path for Swift Package Manager dependencies var clonedSourcePackagesPath: String? { get } + /// Sets a custom package cache path for Swift Package Manager dependencies + var packageCachePath: String? { get } + /// Skips resolution of Swift Package Manager dependencies var skipPackageDependenciesResolution: Bool { get } - /// Prevents packages from automatically being resolved to versions other than those recorded in the `Package.resolved` file + /// Prevents packages from automatically being resolved to versions other than those recorded in the `Package.resolved` file. This translates in the option `-disableAutomaticPackageResolution` being passed to xcodebuild var disablePackageAutomaticUpdates: Bool { get } + /// Skips updating package dependencies from their remote. This translates in the option `-skipPackageUpdates` being passed to xcodebuild + var skipPackageRepositoryFetches: Bool { get } + /// Lets xcodebuild use a specified package authorization provider (keychain|netrc) var packageAuthorizationProvider: String? { get } @@ -154,58 +160,215 @@ public protocol SnapshotfileProtocol: AnyObject { } public extension SnapshotfileProtocol { - var workspace: String? { return nil } - var project: String? { return nil } - var xcargs: String? { return nil } - var xcconfig: String? { return nil } - var devices: [String]? { return nil } - var languages: [String] { return ["en-US"] } - var launchArguments: [String] { return [""] } - var outputDirectory: String { return "screenshots" } - var outputSimulatorLogs: Bool { return false } - var iosVersion: String? { return nil } - var skipOpenSummary: Bool { return false } - var skipHelperVersionCheck: Bool { return false } - var clearPreviousScreenshots: Bool { return false } - var reinstallApp: Bool { return false } - var eraseSimulator: Bool { return false } - var headless: Bool { return true } - var overrideStatusBar: Bool { return false } - var overrideStatusBarArguments: String? { return nil } - var localizeSimulator: Bool { return false } - var darkMode: Bool? { return nil } - var appIdentifier: String? { return nil } - var addPhotos: [String]? { return nil } - var addVideos: [String]? { return nil } - var htmlTemplate: String? { return nil } - var buildlogPath: String { return "~/Library/Logs/snapshot" } - var clean: Bool { return false } - var testWithoutBuilding: Bool? { return nil } - var configuration: String? { return nil } - var sdk: String? { return nil } - var scheme: String? { return nil } - var numberOfRetries: Int { return 1 } - var stopAfterFirstError: Bool { return false } - var derivedDataPath: String? { return nil } - var resultBundle: Bool { return false } - var testTargetName: String? { return nil } - var namespaceLogFiles: String? { return nil } - var concurrentSimulators: Bool { return true } - var disableSlideToType: Bool { return false } - var clonedSourcePackagesPath: String? { return nil } - var skipPackageDependenciesResolution: Bool { return false } - var disablePackageAutomaticUpdates: Bool { return false } - var packageAuthorizationProvider: String? { return nil } - var testplan: String? { return nil } - var onlyTesting: String? { return nil } - var skipTesting: String? { return nil } - var xcodebuildFormatter: String { return "xcbeautify" } - var xcprettyArgs: String? { return nil } - var disableXcpretty: Bool? { return nil } - var suppressXcodeOutput: Bool? { return nil } - var useSystemScm: Bool { return false } + var workspace: String? { + return nil + } + + var project: String? { + return nil + } + + var xcargs: String? { + return nil + } + + var xcconfig: String? { + return nil + } + + var devices: [String]? { + return nil + } + + var languages: [String] { + return ["en-US"] + } + + var launchArguments: [String] { + return [""] + } + + var outputDirectory: String { + return "screenshots" + } + + var outputSimulatorLogs: Bool { + return false + } + + var iosVersion: String? { + return nil + } + + var skipOpenSummary: Bool { + return false + } + + var skipHelperVersionCheck: Bool { + return false + } + + var clearPreviousScreenshots: Bool { + return false + } + + var reinstallApp: Bool { + return false + } + + var eraseSimulator: Bool { + return false + } + + var headless: Bool { + return true + } + + var overrideStatusBar: Bool { + return false + } + + var overrideStatusBarArguments: String? { + return nil + } + + var localizeSimulator: Bool { + return false + } + + var darkMode: Bool? { + return nil + } + + var appIdentifier: String? { + return nil + } + + var addPhotos: [String]? { + return nil + } + + var addVideos: [String]? { + return nil + } + + var htmlTemplate: String? { + return nil + } + + var buildlogPath: String { + return "~/Library/Logs/snapshot" + } + + var clean: Bool { + return false + } + + var testWithoutBuilding: Bool? { + return nil + } + + var configuration: String? { + return nil + } + + var sdk: String? { + return nil + } + + var scheme: String? { + return nil + } + + var numberOfRetries: Int { + return 1 + } + + var stopAfterFirstError: Bool { + return false + } + + var derivedDataPath: String? { + return nil + } + + var resultBundle: Bool { + return false + } + + var testTargetName: String? { + return nil + } + + var namespaceLogFiles: String? { + return nil + } + + var concurrentSimulators: Bool { + return true + } + + var disableSlideToType: Bool { + return false + } + + var clonedSourcePackagesPath: String? { + return nil + } + + var packageCachePath: String? { + return nil + } + + var skipPackageDependenciesResolution: Bool { + return false + } + + var disablePackageAutomaticUpdates: Bool { + return false + } + + var skipPackageRepositoryFetches: Bool { + return false + } + + var packageAuthorizationProvider: String? { + return nil + } + + var testplan: String? { + return nil + } + + var onlyTesting: String? { + return nil + } + + var skipTesting: String? { + return nil + } + + var xcodebuildFormatter: String { + return "xcbeautify" + } + + var xcprettyArgs: String? { + return nil + } + + var disableXcpretty: Bool? { + return nil + } + + var suppressXcodeOutput: Bool? { + return nil + } + + var useSystemScm: Bool { + return false + } } // Please don't remove the lines below // They are used to detect outdated files -// FastlaneRunnerAPIVersion [0.9.130] +// FastlaneRunnerAPIVersion [0.9.137] diff --git a/fastlane/swift/SocketClient.swift b/fastlane/swift/SocketClient.swift index a1fe92b9b..b997614b6 100644 --- a/fastlane/swift/SocketClient.swift +++ b/fastlane/swift/SocketClient.swift @@ -1,5 +1,5 @@ // SocketClient.swift -// Copyright (c) 2025 FastlaneTools +// Copyright (c) 2026 FastlaneTools // // ** NOTE ** @@ -53,7 +53,7 @@ class SocketClient: NSObject { private(set) var socketStatus: SocketStatus - // localhost only, this prevents other computers from connecting + /// localhost only, this prevents other computers from connecting init(host: String = "localhost", port: UInt32 = 2000, commandTimeoutSeconds: Int = defaultCommandTimeoutSeconds, socketDelegate: SocketClientDelegateProtocol) { self.host = host self.port = port diff --git a/fastlane/swift/SocketClientDelegateProtocol.swift b/fastlane/swift/SocketClientDelegateProtocol.swift index 665e42a6a..1f0a6927d 100644 --- a/fastlane/swift/SocketClientDelegateProtocol.swift +++ b/fastlane/swift/SocketClientDelegateProtocol.swift @@ -1,5 +1,5 @@ // SocketClientDelegateProtocol.swift -// Copyright (c) 2025 FastlaneTools +// Copyright (c) 2026 FastlaneTools // // ** NOTE ** diff --git a/fastlane/swift/SocketResponse.swift b/fastlane/swift/SocketResponse.swift index 8f0a87503..3d1c14a5d 100644 --- a/fastlane/swift/SocketResponse.swift +++ b/fastlane/swift/SocketResponse.swift @@ -1,5 +1,5 @@ // SocketResponse.swift -// Copyright (c) 2025 FastlaneTools +// Copyright (c) 2026 FastlaneTools // // ** NOTE ** diff --git a/fastlane/swift/main.swift b/fastlane/swift/main.swift index 08423a206..0374588cf 100644 --- a/fastlane/swift/main.swift +++ b/fastlane/swift/main.swift @@ -1,5 +1,5 @@ // main.swift -// Copyright (c) 2025 FastlaneTools +// Copyright (c) 2026 FastlaneTools // // ** NOTE ** From 4e74011ae2705338ff3b04a23443784324f0672c Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:20:36 +0300 Subject: [PATCH 06/24] MOBILE-130: Route operations endpoints to configurable anonymizer URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds optional `operationsDomain` to `MBConfiguration` so projects served via a CDP anonymizer can route operations and track-visit requests to a separate host, avoiding personal-data leaks to `api.mindbox.ru`. Affected routes (now resolve to `operationsDomain` when configured): - `/v3/operations/{async,sync,async-custom}` - `/v1.1/customer/mobile-track-visit` - SDK logs upload Config-fetch (`/mobile/byendpoint/*.json`) and `/geo` keep using `domain`. Priority of resolution: 1. `settings.baseAddresses.operations` from the mobile JSON config 2. `operationsDomain` passed to `Mindbox.initialization(...)` 3. `nil` → fall back to `MBConfiguration.domain` (existing behavior) The JSON-sourced value is persisted in `UserDefaults` so operations stay pinned to the anonymizer across restarts. An explicit `null` / empty value in a fresh config clears the cache (rollback channel); a format-broken value preserves the previous good one. Backwards compatibility: - `operationsDomain` has a default value of `nil` - `softReset()` preserves `operationsDomainFromConfig` (PD safety on migration resets) - Legacy `MBConfiguration` JSON without the new key decodes fine Tests cover `URLRequestBuilder` host resolution, `MBConfiguration` validation, `Settings` JSON decoding (incl. `null`/empty rollback), `ConfigValidation` behavior, `MBNetworkFetcher` priority resolution, `OperationsDomainConfigPolicy` decision matrix, persistence lifecycle, and legacy-config decoding. Follow-ups (separate commits): - Drop dead `SDKLogsRoute` - Rewrite `URLValidator` (hardcoded TLD list, `&` bug) - Allow optional `https://` scheme in `domain` / `operationsDomain` inputs --- Mindbox.xcodeproj/project.pbxproj | 12 + .../InAppConfigurationManager.swift | 25 +- .../OperationsDomainConfigPolicy.swift | 36 ++ .../Models/Config/BaseAddressesModel.swift | 17 + .../Models/Config/SettingsModel.swift | 4 +- Mindbox/MBConfiguration.swift | 25 +- Mindbox/MindboxLogger/SDKLogsRequest.swift | 1 + Mindbox/Network/Abstract/Route.swift | 12 + .../Network/Helpers/URLRequestBuilder.swift | 17 +- Mindbox/Network/MBNetworkFetcher.swift | 29 +- .../NetworkRepository/Event/EventRoute.swift | 2 + .../MBPersistenceStorage.swift | 8 + .../PersistenceStorage.swift | 9 + MindboxTests/Extensions/Tag+Extensions.swift | 1 + .../InAppConfigStub.swift | 5 +- .../InappTTLTests.swift | 16 +- .../Mock/MockPersistenceStorage.swift | 6 + .../Network/OperationsURLRoutingTests.swift | 327 ++++++++++++++++++ 18 files changed, 534 insertions(+), 18 deletions(-) create mode 100644 Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift create mode 100644 Mindbox/InAppMessages/Models/Config/BaseAddressesModel.swift create mode 100644 MindboxTests/Network/OperationsURLRoutingTests.swift diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index 57265de33..8956fa196 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -11,11 +11,13 @@ 0E7A224A082FA2DA35706CC7 /* MotionServiceResolvePositionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8192B8B7043EF74D05B36B /* MotionServiceResolvePositionTests.swift */; }; 0E7A224A082FA2DA35706CC8 /* MotionServiceShakeToEditTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8192B8B7043EF74D05B36C /* MotionServiceShakeToEditTests.swift */; }; 1E3BD63AB3F1521C253CB818 /* MBNetworkFetcherResponseHandlingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97FEDDEB5F71A67F1C4C675F /* MBNetworkFetcherResponseHandlingTests.swift */; }; + F3BA5E000130A000C0000005 /* OperationsURLRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BA5E000130A000C0000006 /* OperationsURLRoutingTests.swift */; }; 302E35788CBDA959283569F4 /* MotionServiceBehaviorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0DB93A7997961CA7C2BE917 /* MotionServiceBehaviorTests.swift */; }; 313B233A25ADEA0F00A1CB72 /* Mindbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 313B233025ADEA0F00A1CB72 /* Mindbox.framework */; }; 313B233F25ADEA0F00A1CB72 /* MindboxTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 313B233E25ADEA0F00A1CB72 /* MindboxTests.swift */; }; 313B234125ADEA0F00A1CB72 /* Mindbox.h in Headers */ = {isa = PBXBuildFile; fileRef = 313B233325ADEA0F00A1CB72 /* Mindbox.h */; settings = {ATTRIBUTES = (Public, ); }; }; 314B38FD25AEE8B200E947B9 /* MBConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B38FC25AEE8B200E947B9 /* MBConfiguration.swift */; }; + F3BA5E000130A000C0000003 /* BaseAddressesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BA5E000130A000C0000004 /* BaseAddressesModel.swift */; }; 314B390025AEE96F00E947B9 /* CoreController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B38FF25AEE96F00E947B9 /* CoreController.swift */; }; 317054CB25AF189800AE624C /* PersistenceStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317054CA25AF189800AE624C /* PersistenceStorage.swift */; }; 317AF8FC25B844DB006348FA /* UtilitiesFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317AF8FB25B844DB006348FA /* UtilitiesFetcher.swift */; }; @@ -477,6 +479,7 @@ F31470962B96681F00E01E5C /* 27-TargetingRequests.json in Resources */ = {isa = PBXBuildFile; fileRef = F31470952B96681F00E01E5C /* 27-TargetingRequests.json */; }; F31470982B9668F100E01E5C /* 31-TargetingRequests.json in Resources */ = {isa = PBXBuildFile; fileRef = F31470972B9668F100E01E5C /* 31-TargetingRequests.json */; }; F315503F2BBB24E20072A071 /* TTLValidationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F315503E2BBB24E20072A071 /* TTLValidationService.swift */; }; + F3BA5E000130A000C0000007 /* OperationsDomainConfigPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BA5E000130A000C0000008 /* OperationsDomainConfigPolicy.swift */; }; F31909992E979D9E00373E2F /* MindboxAppDelegateProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31909982E979D9E00373E2F /* MindboxAppDelegateProxy.swift */; }; F31A94782BC6995500E6C978 /* InappFrequency.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31A94772BC6995500E6C978 /* InappFrequency.swift */; }; F31A947C2BC69E3900E6C978 /* PeriodicFrequency.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31A947B2BC69E3900E6C978 /* PeriodicFrequency.swift */; }; @@ -739,6 +742,7 @@ 313B233E25ADEA0F00A1CB72 /* MindboxTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindboxTests.swift; sourceTree = ""; }; 313B234025ADEA0F00A1CB72 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 314B38FC25AEE8B200E947B9 /* MBConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MBConfiguration.swift; sourceTree = ""; }; + F3BA5E000130A000C0000004 /* BaseAddressesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseAddressesModel.swift; sourceTree = ""; }; 314B38FF25AEE96F00E947B9 /* CoreController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreController.swift; sourceTree = ""; }; 317054CA25AF189800AE624C /* PersistenceStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceStorage.swift; sourceTree = ""; }; 317AF8FB25B844DB006348FA /* UtilitiesFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UtilitiesFetcher.swift; sourceTree = ""; }; @@ -1056,6 +1060,7 @@ 84FCD3BC25CA10F600D1E574 /* SuccessResponse.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = SuccessResponse.json; sourceTree = ""; }; 9778038796A8426ABDED1E97 /* FeatureTogglesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureTogglesModel.swift; sourceTree = ""; }; 97FEDDEB5F71A67F1C4C675F /* MBNetworkFetcherResponseHandlingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MBNetworkFetcherResponseHandlingTests.swift; sourceTree = ""; }; + F3BA5E000130A000C0000006 /* OperationsURLRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationsURLRoutingTests.swift; sourceTree = ""; }; 9B24FAAB28C74B8300F10B5D /* InAppConfigurationRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppConfigurationRepository.swift; sourceTree = ""; }; 9B24FAAD28C74BA500F10B5D /* InAppCoreManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppCoreManager.swift; sourceTree = ""; }; 9B24FAB028C74BD200F10B5D /* InAppConfigurationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppConfigurationManager.swift; sourceTree = ""; }; @@ -1203,6 +1208,7 @@ F31470952B96681F00E01E5C /* 27-TargetingRequests.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "27-TargetingRequests.json"; sourceTree = ""; }; F31470972B9668F100E01E5C /* 31-TargetingRequests.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "31-TargetingRequests.json"; sourceTree = ""; }; F315503E2BBB24E20072A071 /* TTLValidationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTLValidationService.swift; sourceTree = ""; }; + F3BA5E000130A000C0000008 /* OperationsDomainConfigPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationsDomainConfigPolicy.swift; sourceTree = ""; }; F31909982E979D9E00373E2F /* MindboxAppDelegateProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindboxAppDelegateProxy.swift; sourceTree = ""; }; F31A94772BC6995500E6C978 /* InappFrequency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InappFrequency.swift; sourceTree = ""; }; F31A947B2BC69E3900E6C978 /* PeriodicFrequency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeriodicFrequency.swift; sourceTree = ""; }; @@ -2256,6 +2262,7 @@ isa = PBXGroup; children = ( 97FEDDEB5F71A67F1C4C675F /* MBNetworkFetcherResponseHandlingTests.swift */, + F3BA5E000130A000C0000006 /* OperationsURLRoutingTests.swift */, ); name = Network; path = Network; @@ -3111,6 +3118,7 @@ isa = PBXGroup; children = ( F315503E2BBB24E20072A071 /* TTLValidationService.swift */, + F3BA5E000130A000C0000008 /* OperationsDomainConfigPolicy.swift */, ); path = Services; sourceTree = ""; @@ -3715,6 +3723,7 @@ F3A8B9AA2A3A719C00E9C055 /* ABTestModel.swift */, F3A4EFDB2D5224C700DB96A8 /* SlidingExpirationModel.swift */, 9778038796A8426ABDED1E97 /* FeatureTogglesModel.swift */, + F3BA5E000130A000C0000004 /* BaseAddressesModel.swift */, ); path = Config; sourceTree = ""; @@ -4385,6 +4394,7 @@ F3A8B9A32A3A6E6900E9C055 /* SdkVersionModel.swift in Sources */, 3333C1DE2681E9F300B60D84 /* URLRequestBuilder.swift in Sources */, F315503F2BBB24E20072A071 /* TTLValidationService.swift in Sources */, + F3BA5E000130A000C0000007 /* OperationsDomainConfigPolicy.swift in Sources */, 334F3AF5264C199900A6AC00 /* CodableDictionary.swift in Sources */, F3F5BB8A2B79F2600022AC3F /* PushNotificationFormatter.swift in Sources */, 334F3AF3264C199900A6AC00 /* AreaRequest.swift in Sources */, @@ -4554,6 +4564,7 @@ 6FDD1445266F7C2200A50C35 /* CouponResponse.swift in Sources */, F78E92EF282E63320003B4A3 /* DispatchSemaphore.swift in Sources */, 314B38FD25AEE8B200E947B9 /* MBConfiguration.swift in Sources */, + F3BA5E000130A000C0000003 /* BaseAddressesModel.swift in Sources */, F331DD0C2A83A56500222120 /* ViewFactoryProtocol.swift in Sources */, 6FDD1447266F7C2B00A50C35 /* LimitResponse.swift in Sources */, 847F580725C88C7A00147A9A /* NetworkFetcher.swift in Sources */, @@ -4722,6 +4733,7 @@ 0E7A224A082FA2DA35706CC8 /* MotionServiceShakeToEditTests.swift in Sources */, 302E35788CBDA959283569F4 /* MotionServiceBehaviorTests.swift in Sources */, 1E3BD63AB3F1521C253CB818 /* MBNetworkFetcherResponseHandlingTests.swift in Sources */, + F3BA5E000130A000C0000005 /* OperationsURLRoutingTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift b/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift index 606c4cc9b..9d3ad9e51 100644 --- a/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift +++ b/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift @@ -156,16 +156,37 @@ class InAppConfigurationManager: InAppConfigurationManagerProtocol { if let viewProduct = settings.operations?.viewProduct { SessionTemporaryStorage.shared.viewProductOperation = viewProduct.systemName.lowercased() } - + if let inappSettings = settings.inapp { SessionTemporaryStorage.shared.inAppSettings = inappSettings } featureToggleManager.applyFeatureToggles(settings.featureToggles) - + + persistOperationsDomain(from: settings.baseAddresses) + 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: + if let raw = raw, !raw.isEmpty, current != raw { + // `.keep` on a non-empty value means it was rejected by URLValidator. + Logger.common(message: "[OperationsDomain] Invalid domain from config — ignored, previous value kept. [Value]: \(raw)", level: .error, category: .inAppMessages) + } + 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) + } + } + private func createTTLValidationService() -> TTLValidationProtocol { return TTLValidationService(persistenceStorage: self.persistenceStorage) } diff --git a/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift new file mode 100644 index 000000000..bec7e4616 --- /dev/null +++ b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift @@ -0,0 +1,36 @@ +// +// 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). + case clear + /// No-op: equal to stored, both empty, or incoming value is format-broken + /// (one bad push must not destroy a working config). + case keep + } + + static func action(for raw: String?, currentlyStored: String?) -> Action { + guard let value = raw, !value.isEmpty else { + return currentlyStored == nil ? .keep : .clear + } + + guard let url = URL(string: "https://" + value), + URLValidator(url: url).evaluate() else { + return .keep + } + + return value == currentlyStored ? .keep : .save(value) + } +} diff --git a/Mindbox/InAppMessages/Models/Config/BaseAddressesModel.swift b/Mindbox/InAppMessages/Models/Config/BaseAddressesModel.swift new file mode 100644 index 000000000..16acc0dad --- /dev/null +++ b/Mindbox/InAppMessages/Models/Config/BaseAddressesModel.swift @@ -0,0 +1,17 @@ +// +// 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? + } +} diff --git a/Mindbox/InAppMessages/Models/Config/SettingsModel.swift b/Mindbox/InAppMessages/Models/Config/SettingsModel.swift index d8c51260c..120cc814f 100644 --- a/Mindbox/InAppMessages/Models/Config/SettingsModel.swift +++ b/Mindbox/InAppMessages/Models/Config/SettingsModel.swift @@ -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 } } @@ -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) } } diff --git a/Mindbox/MBConfiguration.swift b/Mindbox/MBConfiguration.swift index f4490f6c5..1091c6534 100644 --- a/Mindbox/MBConfiguration.swift +++ b/Mindbox/MBConfiguration.swift @@ -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 @@ -31,6 +32,9 @@ public struct MBConfiguration: Codable { /// - Parameter subscribeCustomerIfCreated: Flag which determines subscription status of the user. Default value is `false`. /// - Parameter shouldCreateCustomer: Flag which determines create or not anonymous users. Usable only during first initialisation. Default value is `true`. /// - Parameter uuidDebugEnabled: Flag which determines if uuid debugging functionality is enabled. Default value is `true`. + /// - Parameter operationsDomain: Optional anonymizer host for `/v3/operations/*` and + /// `/v1.1/customer/mobile-track-visit`. Bare host without scheme. Overridden by the + /// value from the mobile JSON config when present. Default `nil` (use `domain`). /// /// - Throws:`MindboxError.internalError` for invalid initialization parameters public init( @@ -41,7 +45,8 @@ public struct MBConfiguration: Codable { subscribeCustomerIfCreated: Bool = false, shouldCreateCustomer: Bool = true, imageLoadingMaxTimeInSeconds: Double? = nil, - uuidDebugEnabled: Bool = true + uuidDebugEnabled: Bool = true, + operationsDomain: String? = nil ) throws { self.endpoint = endpoint self.domain = domain @@ -58,6 +63,17 @@ public struct MBConfiguration: Codable { throw error } + if let operationsDomain = operationsDomain, !operationsDomain.isEmpty { + guard let url = URL(string: "https://" + operationsDomain), URLValidator(url: url).evaluate() 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 @@ -137,6 +153,7 @@ public struct MBConfiguration: Codable { enum CodingKeys: String, CodingKey { case endpoint case domain + case operationsDomain case previousInstallationId case previousDeviceUUID case subscribeCustomerIfCreated @@ -148,6 +165,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 { @@ -170,7 +188,8 @@ public struct MBConfiguration: Codable { previousDeviceUUID: previousDeviceUUID, subscribeCustomerIfCreated: subscribeCustomerIfCreated, shouldCreateCustomer: shouldCreateCustomer, - uuidDebugEnabled: uuidDebugEnabled + uuidDebugEnabled: uuidDebugEnabled, + operationsDomain: operationsDomain ) } } @@ -191,6 +210,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 diff --git a/Mindbox/MindboxLogger/SDKLogsRequest.swift b/Mindbox/MindboxLogger/SDKLogsRequest.swift index cf228db30..a952d9930 100644 --- a/Mindbox/MindboxLogger/SDKLogsRequest.swift +++ b/Mindbox/MindboxLogger/SDKLogsRequest.swift @@ -19,6 +19,7 @@ struct SDKLogsRoute: Route { var headers: HTTPHeaders? { nil } var queryParameters: QueryParameters { .init() } var body: Data? + var baseURLKind: RouteBaseURL { .operations } func makeBasicQueryParameters(with wrapper: EventWrapper) -> QueryParameters { ["transactionId": wrapper.event.transactionId, diff --git a/Mindbox/Network/Abstract/Route.swift b/Mindbox/Network/Abstract/Route.swift index 9712ef439..88b4fa1b1 100644 --- a/Mindbox/Network/Abstract/Route.swift +++ b/Mindbox/Network/Abstract/Route.swift @@ -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 } @@ -19,4 +25,10 @@ protocol Route { var queryParameters: QueryParameters { get } var body: Data? { get } + + var baseURLKind: RouteBaseURL { get } +} + +extension Route { + var baseURLKind: RouteBaseURL { .domain } } diff --git a/Mindbox/Network/Helpers/URLRequestBuilder.swift b/Mindbox/Network/Helpers/URLRequestBuilder.swift index 0b4d53b61..86cd71810 100644 --- a/Mindbox/Network/Helpers/URLRequestBuilder.swift +++ b/Mindbox/Network/Helpers/URLRequestBuilder.swift @@ -12,6 +12,12 @@ import MindboxLogger struct URLRequestBuilder { let domain: String + let operationsDomain: String? + + init(domain: String, operationsDomain: String? = nil) { + self.domain = domain + self.operationsDomain = operationsDomain + } func asURLRequest(route: Route) throws -> URLRequest { let components = makeURLComponents(for: route) @@ -34,13 +40,22 @@ struct URLRequestBuilder { private func makeURLComponents(for route: Route) -> URLComponents { var components = URLComponents() components.scheme = "https" - components.host = domain + components.host = resolvedHost(for: route) components.path = route.path components.queryItems = makeQueryItems(for: route.queryParameters) return components } + private func resolvedHost(for route: Route) -> String { + switch route.baseURLKind { + case .domain: + return domain + case .operations: + return operationsDomain ?? domain + } + } + private func makeQueryItems(for parameters: QueryParameters?) -> [URLQueryItem]? { return parameters?.compactMap { URLQueryItem(name: $0.key, value: $0.value.description) } } diff --git a/Mindbox/Network/MBNetworkFetcher.swift b/Mindbox/Network/MBNetworkFetcher.swift index 9da733fda..123bee386 100644 --- a/Mindbox/Network/MBNetworkFetcher.swift +++ b/Mindbox/Network/MBNetworkFetcher.swift @@ -56,7 +56,10 @@ class MBNetworkFetcher: NetworkFetcher { return } - let builder = URLRequestBuilder(domain: configuration.domain) + let builder = URLRequestBuilder( + domain: configuration.domain, + operationsDomain: resolvedOperationsDomain(configuration: configuration) + ) do { let urlRequest = try builder.asURLRequest(route: route) Logger.network(request: urlRequest, httpAdditionalHeaders: session.configuration.httpAdditionalHeaders) @@ -102,7 +105,10 @@ class MBNetworkFetcher: NetworkFetcher { completion(.failure(error)) return } - let builder = URLRequestBuilder(domain: configuration.domain) + let builder = URLRequestBuilder( + domain: configuration.domain, + operationsDomain: resolvedOperationsDomain(configuration: configuration) + ) do { let urlRequest = try builder.asURLRequest(route: route) Logger.network(request: urlRequest, httpAdditionalHeaders: session.configuration.httpAdditionalHeaders) @@ -368,4 +374,23 @@ class MBNetworkFetcher: NetworkFetcher { tasks.forEach { $0.cancel() } } } + + private func resolvedOperationsDomain(configuration: MBConfiguration) -> String? { + Self.resolveOperationsDomain( + fromConfigJSON: persistenceStorage.operationsDomainFromConfig, + fromInit: configuration.operationsDomain + ) + } + + /// Priority: JSON config > init > nil. Empty strings count as "no value". + /// Static for unit-testing without a PersistenceStorage or MBConfiguration. + static func resolveOperationsDomain(fromConfigJSON: String?, fromInit: String?) -> String? { + if let fromConfig = fromConfigJSON, !fromConfig.isEmpty { + return fromConfig + } + if let fromInit = fromInit, !fromInit.isEmpty { + return fromInit + } + return nil + } } diff --git a/Mindbox/NetworkRepository/Event/EventRoute.swift b/Mindbox/NetworkRepository/Event/EventRoute.swift index 940962eb3..9ff8d4c90 100644 --- a/Mindbox/NetworkRepository/Event/EventRoute.swift +++ b/Mindbox/NetworkRepository/Event/EventRoute.swift @@ -21,6 +21,8 @@ enum EventRoute: Route { } } + var baseURLKind: RouteBaseURL { .operations } + var path: String { switch self { case .syncEvent: diff --git a/Mindbox/PersistenceStorage/MBPersistenceStorage.swift b/Mindbox/PersistenceStorage/MBPersistenceStorage.swift index a851dfd53..d35f5a39a 100644 --- a/Mindbox/PersistenceStorage/MBPersistenceStorage.swift +++ b/Mindbox/PersistenceStorage/MBPersistenceStorage.swift @@ -268,6 +268,13 @@ class MBPersistenceStorage: PersistenceStorage { @UserDefaultsWrapper(key: .webViewLocalStateVersion, defaultValue: nil) var webViewLocalStateVersion: Int? + @UserDefaultsWrapper(key: .operationsDomainFromConfig, defaultValue: nil) + var operationsDomainFromConfig: String? { + didSet { + onDidChange?() + } + } + // MARK: - Deprecated Properties // These properties are deprecated and will be removed in future versions. // Please use the recommended alternatives instead. @@ -306,6 +313,7 @@ extension MBPersistenceStorage { case applicationInfoUpdateVersion = "MBPersistenceStorage-applicationInfoUpdatedVersion" case applicationInstanceId = "MBPersistenceStorage-applicationInstanceId" case webViewLocalStateVersion = "MBPersistenceStorage-webViewLocalStateVersion" + case operationsDomainFromConfig = "MBPersistenceStorage-operationsDomainFromConfig" // MARK: - Deprecated Keys // These keys are deprecated and will be removed in future versions. diff --git a/Mindbox/PersistenceStorage/PersistenceStorage.swift b/Mindbox/PersistenceStorage/PersistenceStorage.swift index 95e268a68..45e22e2bf 100644 --- a/Mindbox/PersistenceStorage/PersistenceStorage.swift +++ b/Mindbox/PersistenceStorage/PersistenceStorage.swift @@ -52,6 +52,11 @@ protocol PersistenceStorage: AnyObject { /// It is optional and can be set to `nil` if the configuration has not yet been downloaded yet or reset. var configDownloadDate: Date? { get set } + /// Operations host cached from `settings.baseAddresses.operations` in the mobile + /// JSON config. Persisted across launches; takes precedence over the init-time + /// `MBConfiguration.operationsDomain` at request time. + var operationsDomainFromConfig: String? { get set } + /// The version code used to track the current state of migrations. /// This value is compared to `Constants.Migration.sdkVersionCode` to determine /// if migrations need to be performed. If a migration fails, and the `versionCodeForMigration` @@ -97,6 +102,9 @@ extension PersistenceStorage { func softReset() { configDownloadDate = nil + // `operationsDomainFromConfig` is intentionally preserved: clearing it on a + // migration reset would route operations to `domain` until config reloads, + // breaking the PD-safety guarantee. shownDatesByInApp = nil handledlogRequestIds = nil lastInappStateChangeDate = nil @@ -119,6 +127,7 @@ extension PersistenceStorage { configuration = nil isNotificationsEnabled = nil configDownloadDate = nil + operationsDomainFromConfig = nil applicationInstanceId = nil applicationInfoUpdateVersion = nil } diff --git a/MindboxTests/Extensions/Tag+Extensions.swift b/MindboxTests/Extensions/Tag+Extensions.swift index 27b506597..0cd6600d6 100644 --- a/MindboxTests/Extensions/Tag+Extensions.swift +++ b/MindboxTests/Extensions/Tag+Extensions.swift @@ -25,4 +25,5 @@ extension Tag { @Tag static var geoTargeting: Self @Tag static var webView: Self @Tag static var trackVisit: Self + @Tag static var operationsRouting: Self } diff --git a/MindboxTests/InApp/Tests/InAppConfigResponseTests/InAppConfigStub.swift b/MindboxTests/InApp/Tests/InAppConfigResponseTests/InAppConfigStub.swift index 1e54fa63c..be0e2dbd9 100644 --- a/MindboxTests/InApp/Tests/InAppConfigResponseTests/InAppConfigStub.swift +++ b/MindboxTests/InApp/Tests/InAppConfigResponseTests/InAppConfigStub.swift @@ -115,10 +115,11 @@ extension InAppConfigStub { viewCategory: operationType == .viewCategory ? .init(systemName: "Mobile.ViewCategory") : nil, setCart: nil ), - ttl: nil, + ttl: nil, slidingExpiration: nil, inapp: nil, - featureToggles: nil + featureToggles: nil, + baseAddresses: nil ) // Mock method setupSettingsFromConfig. diff --git a/MindboxTests/InApp/Tests/InAppConfigResponseTests/InappTTLTests.swift b/MindboxTests/InApp/Tests/InAppConfigResponseTests/InappTTLTests.swift index 9a8a77ca3..dc52be6dc 100644 --- a/MindboxTests/InApp/Tests/InAppConfigResponseTests/InappTTLTests.swift +++ b/MindboxTests/InApp/Tests/InAppConfigResponseTests/InappTTLTests.swift @@ -27,7 +27,7 @@ class InappTTLTests: XCTestCase { func testNeedResetInapps_WithTTL_Exceeds() throws { persistenceStorage.configDownloadDate = Calendar.current.date(byAdding: .hour, value: -2, to: Date()) - let settings = Settings(operations: nil, ttl: .init(inapps: "01:00:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil) + let settings = Settings(operations: nil, ttl: .init(inapps: "01:00:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil, baseAddresses: nil) let config = ConfigResponse(settings: settings) let result = service.needResetInapps(config: config) XCTAssertTrue(result, "Inapps должны быть сброшены, так как время ttl истекло.") @@ -35,7 +35,7 @@ class InappTTLTests: XCTestCase { func testNeedResetInapps_WithTTL_NotExceeded() throws { persistenceStorage.configDownloadDate = Calendar.current.date(byAdding: .second, value: -1, to: Date()) - let settings = Settings(operations: nil, ttl: .init(inapps: "00:00:02"), slidingExpiration: nil, inapp: nil, featureToggles: nil) + let settings = Settings(operations: nil, ttl: .init(inapps: "00:00:02"), slidingExpiration: nil, inapp: nil, featureToggles: nil, baseAddresses: nil) let config = ConfigResponse(settings: settings) let result = service.needResetInapps(config: config) XCTAssertFalse(result, "Inapps не должны быть сброшены, так как время ttl еще не истекло.") @@ -43,7 +43,7 @@ class InappTTLTests: XCTestCase { func testNeedResetInapps_WithoutTTL() throws { persistenceStorage.configDownloadDate = Date() - let settings = Settings(operations: nil, ttl: nil, slidingExpiration: nil, inapp: nil, featureToggles: nil) + let settings = Settings(operations: nil, ttl: nil, slidingExpiration: nil, inapp: nil, featureToggles: nil, baseAddresses: nil) let config = ConfigResponse(settings: settings) let result = service.needResetInapps(config: config) XCTAssertFalse(result, "Inapps не должны быть сброшены, так как в конфиге отсутствует TTL.") @@ -51,7 +51,7 @@ class InappTTLTests: XCTestCase { func testNeedResetInapps_WithTTLHalfHourAgo_NotExceeded() throws { persistenceStorage.configDownloadDate = Calendar.current.date(byAdding: .minute, value: -30, to: Date()) - let settings = Settings(operations: nil, ttl: .init(inapps: "01:00:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil) + let settings = Settings(operations: nil, ttl: .init(inapps: "01:00:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil, baseAddresses: nil) let config = ConfigResponse(settings: settings) let result = service.needResetInapps(config: config) XCTAssertFalse(result, "Inapps не должны быть сброшены, так как время TTL еще не истекло.") @@ -59,7 +59,7 @@ class InappTTLTests: XCTestCase { func testNeedResetInapps_WithTTLHalfMinutesAgo_NotExceeded() throws { persistenceStorage.configDownloadDate = Calendar.current.date(byAdding: .second, value: -30, to: Date()) - let settings = Settings(operations: nil, ttl: .init(inapps: "00:01:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil) + let settings = Settings(operations: nil, ttl: .init(inapps: "00:01:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil, baseAddresses: nil) let config = ConfigResponse(settings: settings) let result = service.needResetInapps(config: config) XCTAssertFalse(result, "Inapps не должны быть сброшены, так как время TTL еще не истекло.") @@ -67,7 +67,7 @@ class InappTTLTests: XCTestCase { func testNeedResetInapps_WithTTLOneDayAgo_NotExceeded() throws { persistenceStorage.configDownloadDate = Calendar.current.date(byAdding: .day, value: -1, to: Date()) - let settings = Settings(operations: nil, ttl: .init(inapps: "2.00:00:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil) + let settings = Settings(operations: nil, ttl: .init(inapps: "2.00:00:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil, baseAddresses: nil) let config = ConfigResponse(settings: settings) let result = service.needResetInapps(config: config) XCTAssertFalse(result, "Inapps не должны быть сброшены, так как время TTL еще не истекло.") @@ -75,7 +75,7 @@ class InappTTLTests: XCTestCase { func testNeedResetInapps_WithMinusTTL_NotExceeded() throws { persistenceStorage.configDownloadDate = Calendar.current.date(byAdding: .day, value: 1, to: Date()) - let settings = Settings(operations: nil, ttl: .init(inapps: "-2.00:00:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil) + let settings = Settings(operations: nil, ttl: .init(inapps: "-2.00:00:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil, baseAddresses: nil) let config = ConfigResponse(settings: settings) let result = service.needResetInapps(config: config) XCTAssertFalse(result, "Inapps не должны быть сброшены, так как время TTL еще не истекло.") @@ -83,7 +83,7 @@ class InappTTLTests: XCTestCase { func testNeedResetInapps_WithMinusOneDayTTL_NotExceeded() throws { persistenceStorage.configDownloadDate = Calendar.current.date(byAdding: .day, value: -2, to: Date()) - let settings = Settings(operations: nil, ttl: .init(inapps: "-1.00:00:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil) + let settings = Settings(operations: nil, ttl: .init(inapps: "-1.00:00:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil, baseAddresses: nil) let config = ConfigResponse(settings: settings) let result = service.needResetInapps(config: config) XCTAssertFalse(result, "Inapps не должны быть сброшены, так как время TTL еще не истекло.") diff --git a/MindboxTests/Mock/MockPersistenceStorage.swift b/MindboxTests/Mock/MockPersistenceStorage.swift index 3c3ebfdef..f7e587146 100644 --- a/MindboxTests/Mock/MockPersistenceStorage.swift +++ b/MindboxTests/Mock/MockPersistenceStorage.swift @@ -123,4 +123,10 @@ class MockPersistenceStorage: PersistenceStorage { var applicationInstanceId: String? var webViewLocalStateVersion: Int? + + var operationsDomainFromConfig: String? { + didSet { + onDidChange?() + } + } } diff --git a/MindboxTests/Network/OperationsURLRoutingTests.swift b/MindboxTests/Network/OperationsURLRoutingTests.swift new file mode 100644 index 000000000..2a18103ab --- /dev/null +++ b/MindboxTests/Network/OperationsURLRoutingTests.swift @@ -0,0 +1,327 @@ +// +// OperationsURLRoutingTests.swift +// MindboxTests +// +// Created by Sergei Semko on 4/27/26. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +import Testing +@testable import Mindbox + +@Suite("Operations URL routing", .tags(.operationsRouting)) +struct OperationsURLRoutingTests { + + private let domain = "api.mindbox.ru" + private let opsHost = "anonymizer-api-regular.client.ru" + + // MARK: - URLRequestBuilder host resolution + + @Test("Event routes use operationsDomain when configured") + func eventRoutesUseOperationsDomain() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: opsHost) + let wrapper = Self.makeEventWrapper(.installed) + + #expect(try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url?.host == opsHost) + #expect(try builder.asURLRequest(route: EventRoute.customAsyncEvent(wrapper)).url?.host == opsHost) + #expect(try builder.asURLRequest(route: EventRoute.trackVisit(wrapper)).url?.host == opsHost) + + let syncWrapper = Self.makeEventWrapper(.syncEvent, bodyJSON: #"{"name":"X","payload":"{}"}"#) + #expect(try builder.asURLRequest(route: EventRoute.syncEvent(syncWrapper)).url?.host == opsHost) + } + + @Test("SDKLogsRoute uses operationsDomain when configured") + func sdkLogsRouteUsesOperationsDomain() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: opsHost) + let url = try builder.asURLRequest(route: SDKLogsRoute()).url + #expect(url?.host == opsHost) + } + + @Test("Config and geo routes always use domain") + func domainRoutesIgnoreOperationsDomain() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: opsHost) + + let geoURL = try builder.asURLRequest(route: FetchInAppGeoRoute()).url + #expect(geoURL?.host == domain) + #expect(geoURL?.path == "/geo") + } + + @Test("No operationsDomain → all routes fall back to domain (backwards compatibility)") + func noOperationsDomainFallsBackToDomain() throws { + let builder = URLRequestBuilder(domain: domain) + let wrapper = Self.makeEventWrapper(.installed) + + #expect(try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url?.host == domain) + #expect(try builder.asURLRequest(route: EventRoute.trackVisit(wrapper)).url?.host == domain) + #expect(try builder.asURLRequest(route: SDKLogsRoute()).url?.host == domain) + #expect(try builder.asURLRequest(route: FetchInAppGeoRoute()).url?.host == domain) + } + + @Test("Path and query parameters survive host swap") + func pathAndQueryUnchangedOnHostSwap() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: opsHost) + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.path == "/v3/operations/async") + #expect(url?.query?.contains("operation=MobilePush.ApplicationInstalled") == true) + #expect(url?.query?.contains("endpointId=test-endpoint") == true) + } + + // MARK: - MBConfiguration validation + + @Test("MBConfiguration accepts nil operationsDomain (backwards compatible)") + func configAcceptsNilOperationsDomain() throws { + let config = try MBConfiguration(endpoint: "e", domain: domain) + #expect(config.operationsDomain == nil) + } + + @Test("MBConfiguration accepts valid operationsDomain") + func configAcceptsValidOperationsDomain() throws { + let config = try MBConfiguration( + endpoint: "e", + domain: domain, + operationsDomain: opsHost + ) + #expect(config.operationsDomain == opsHost) + } + + @Test("MBConfiguration treats empty operationsDomain as nil") + func configTreatsEmptyOperationsDomainAsNil() throws { + let config = try MBConfiguration( + endpoint: "e", + domain: domain, + operationsDomain: "" + ) + #expect(config.operationsDomain == nil) + } + + @Test("MBConfiguration rejects invalid operationsDomain") + func configRejectsInvalidOperationsDomain() { + #expect(throws: MindboxError.self) { + _ = try MBConfiguration( + endpoint: "e", + domain: domain, + operationsDomain: "not a host with spaces" + ) + } + } + + // MARK: - Config JSON decoding (backend schema preserves `baseAddresses.operations`) + + @Test("Settings decodes baseAddresses.operations", .tags(.decoding)) + func settingsDecodesBaseAddresses() throws { + let json = """ + { + "settings": { + "baseAddresses": { "operations": "anonymizer.client.ru" }, + "ttl": { "inapps": "1.00:00:00" } + } + } + """.data(using: .utf8)! + + let response = try JSONDecoder().decode(ConfigResponse.self, from: json) + #expect(response.settings?.baseAddresses?.operations == "anonymizer.client.ru") + } + + @Test("Settings tolerates missing baseAddresses", .tags(.decoding)) + func settingsWithoutBaseAddresses() throws { + let json = """ + { "settings": { "ttl": { "inapps": "1.00:00:00" } } } + """.data(using: .utf8)! + + let response = try JSONDecoder().decode(ConfigResponse.self, from: json) + #expect(response.settings?.baseAddresses == nil) + } + + @Test("Settings decodes explicit null as rollback signal", .tags(.decoding)) + func settingsDecodesNullOperationsAsRollback() throws { + let json = """ + { "settings": { "baseAddresses": { "operations": null } } } + """.data(using: .utf8)! + + let response = try JSONDecoder().decode(ConfigResponse.self, from: json) + #expect(response.settings?.baseAddresses != nil) + #expect(response.settings?.baseAddresses?.operations == nil) + } + + @Test("Settings decodes empty string as rollback signal", .tags(.decoding)) + func settingsDecodesEmptyOperationsAsRollback() throws { + let json = """ + { "settings": { "baseAddresses": { "operations": "" } } } + """.data(using: .utf8)! + + let response = try JSONDecoder().decode(ConfigResponse.self, from: json) + #expect(response.settings?.baseAddresses?.operations == "") + } + + // MARK: - ConfigValidation.compare + + @Test("ConfigValidation does NOT flag operationsDomain change — new value applies without re-install") + func configValidationIgnoresOperationsDomainChange() throws { + let lhs = try MBConfiguration( + endpoint: "e", + domain: domain, + operationsDomain: "old.client.ru" + ) + let rhs = try MBConfiguration( + endpoint: "e", + domain: domain, + operationsDomain: "new.client.ru" + ) + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .none) + } + + @Test("ConfigValidation still flags domain change as REST (operationsDomain unaffected)") + func configValidationDetectsDomainChange() throws { + let lhs = try MBConfiguration(endpoint: "e", domain: "a.mindbox.ru", + operationsDomain: opsHost) + let rhs = try MBConfiguration(endpoint: "e", domain: "b.mindbox.ru", + operationsDomain: opsHost) + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .rest) + } + + // MARK: - Priority resolution (MBNetworkFetcher) + + @Test("Priority — JSON wins when both JSON and init are set") + func priorityJSONWinsOverInit() { + let resolved = MBNetworkFetcher.resolveOperationsDomain( + fromConfigJSON: "json.example.ru", + fromInit: "init.example.ru" + ) + #expect(resolved == "json.example.ru") + } + + @Test("Priority — init used when JSON has nothing") + func priorityInitUsedWhenJSONMissing() { + let resolved = MBNetworkFetcher.resolveOperationsDomain( + fromConfigJSON: nil, + fromInit: "init.example.ru" + ) + #expect(resolved == "init.example.ru") + } + + @Test("Priority — returns nil (→ domain fallback) when neither set") + func priorityNilWhenNeitherSet() { + let resolved = MBNetworkFetcher.resolveOperationsDomain( + fromConfigJSON: nil, + fromInit: nil + ) + #expect(resolved == nil) + } + + @Test("Priority — empty-string JSON treated as no value, falls through to init") + func priorityEmptyStringJSONFallsThrough() { + let resolved = MBNetworkFetcher.resolveOperationsDomain( + fromConfigJSON: "", + fromInit: "init.example.ru" + ) + #expect(resolved == "init.example.ru") + } + + @Test("Priority — empty-string init also treated as no value") + func priorityEmptyStringInitFallsThrough() { + let resolved = MBNetworkFetcher.resolveOperationsDomain( + fromConfigJSON: nil, + fromInit: "" + ) + #expect(resolved == nil) + } + + // MARK: - OperationsDomainConfigPolicy (decides save / clear / keep from JSON) + + @Test("Policy — saves a new valid value when storage is empty") + func policySavesNewValueFromEmpty() { + #expect(OperationsDomainConfigPolicy.action(for: "x.ru", currentlyStored: nil) == .save("x.ru")) + } + + @Test("Policy — saves when value changes") + func policySavesOnChange() { + #expect(OperationsDomainConfigPolicy.action(for: "new.ru", currentlyStored: "old.ru") == .save("new.ru")) + } + + @Test("Policy — keeps when incoming value equals stored") + func policyKeepsOnIdenticalValue() { + #expect(OperationsDomainConfigPolicy.action(for: "x.ru", currentlyStored: "x.ru") == .keep) + } + + @Test("Policy — clears on null/missing config when something is stored") + func policyClearsOnNullWhenStored() { + #expect(OperationsDomainConfigPolicy.action(for: nil, currentlyStored: "old.ru") == .clear) + } + + @Test("Policy — clears on empty string when something is stored") + func policyClearsOnEmptyWhenStored() { + #expect(OperationsDomainConfigPolicy.action(for: "", currentlyStored: "old.ru") == .clear) + } + + @Test("Policy — no-ops when nothing stored and nothing came") + func policyKeepsOnNothingToChange() { + #expect(OperationsDomainConfigPolicy.action(for: nil, currentlyStored: nil) == .keep) + #expect(OperationsDomainConfigPolicy.action(for: "", currentlyStored: nil) == .keep) + } + + @Test("Policy — preserves previous value when incoming host is format-broken") + func policyKeepsOnInvalidFormat() { + #expect(OperationsDomainConfigPolicy.action(for: "host with spaces", currentlyStored: "good.ru") == .keep) + } + + // MARK: - Persistence lifecycle + + @Test("softReset preserves operationsDomainFromConfig (no PD leak on migration reset)") + func softResetPreservesOperationsDomain() { + let storage = MockPersistenceStorage() + storage.operationsDomainFromConfig = "cached-anonymizer.ru" + storage.configDownloadDate = Date() + + storage.softReset() + + #expect(storage.operationsDomainFromConfig == "cached-anonymizer.ru") + #expect(storage.configDownloadDate == nil) + } + + @Test("reset clears operationsDomainFromConfig (test-only hard reset)") + func hardResetClearsOperationsDomain() { + let storage = MockPersistenceStorage() + storage.operationsDomainFromConfig = "cached.ru" + + storage.reset() + + #expect(storage.operationsDomainFromConfig == nil) + } + + // MARK: - Backwards compatibility + + @Test("MBConfiguration decodes legacy JSON without operationsDomain key", .tags(.decoding)) + func decodesLegacyConfigWithoutOperationsDomain() throws { + let legacyJSON = """ + { + "endpoint": "app-IOS", + "domain": "api.mindbox.ru", + "subscribeCustomerIfCreated": false, + "shouldCreateCustomer": true, + "uuidDebugEnabled": true + } + """.data(using: .utf8)! + + let config = try JSONDecoder().decode(MBConfiguration.self, from: legacyJSON) + #expect(config.endpoint == "app-IOS") + #expect(config.domain == "api.mindbox.ru") + #expect(config.operationsDomain == nil) + } + + // MARK: - Helpers + + private static func makeEventWrapper( + _ type: Event.Operation, + bodyJSON: String = "{}" + ) -> EventWrapper { + let event = Event(type: type, body: bodyJSON) + return EventWrapper(event: event, endpoint: "test-endpoint", deviceUUID: "F47AC10B-58CC-4372-A567-0E02B2C3D479") + } +} From ff6951b423da4ba1be99f591d8bba9a14a867f00 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:13:11 +0300 Subject: [PATCH 07/24] MOBILE-130: Test settings.baseAddresses parsing from mobile config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends `SettingsConfigParsingTests` with `baseAddresses` cases from the Pkl error stubs (`Error`, `TypeError`, `OperationsError`, `OperationsTypeError`) and a positive assertion in `test_SettingsConfig_shouldParseSuccessfully`. Updates JSON fixtures under `SettingsJsonStubs/` to carry the new `baseAddresses` block (sourced from `pkl-mobile-config`); adds `BaseAddressesError/` with four fixtures and wires them into the test bundle via `Mindbox.xcodeproj/project.pbxproj`. Switches `Settings.BaseAddresses` to a custom `init(from:)` with per-field `try?` so a type error on `operations` nils only that field — matching `FeatureToggles`/`SlidingExpiration`/`InAppSettings`. Drops two parsing tests from `OperationsURLRoutingTests` whose coverage is now duplicated (positive parse, missing `baseAddresses`). Rollback-channel cases (`null` and empty string) stay there — they exercise feature behavior, not schema parsing. --- Mindbox.xcodeproj/project.pbxproj | 24 +++++++ .../Models/Config/BaseAddressesModel.swift | 11 ++++ .../Settings/SettingsConfigParsingTests.swift | 62 ++++++++++++++++++- .../SettingsBaseAddressesError.json | 31 ++++++++++ .../SettingsBaseAddressesOperationsError.json | 31 ++++++++++ ...tingsBaseAddressesOperationsTypeError.json | 31 ++++++++++ .../SettingsBaseAddressesTypeError.json | 29 +++++++++ .../SettingsFeatureTogglesError.json | 46 +++++++------- ...eTogglesShouldSendInAppShowErrorFalse.json | 46 +++++++------- ...ogglesShouldSendInAppShowErrorMissing.json | 43 +++++++------ ...glesShouldSendInAppShowErrorTypeError.json | 46 +++++++------- .../SettingsFeatureTogglesTypeError.json | 44 ++++++------- .../SettingsAllOperationsWithErrors.json | 3 + .../SettingsAllOperationsWithTypeErrors.json | 3 + .../SettingsOperationsError.json | 3 + .../SettingsOperationsTypeError.json | 3 + ...OperationsViewCategoryAndSetCartError.json | 3 + ...ViewCategoryAndSetCartSystemNameError.json | 3 + ...ategoryAndSetCartSystemNameMixedError.json | 3 + ...CategoryAndSetCartSystemNameTypeError.json | 3 + ...ationsViewCategoryAndSetCartTypeError.json | 3 + .../SettingsOperationsViewProductError.json | 3 + ...sOperationsViewProductSystemNameError.json | 3 + ...rationsViewProductSystemNameTypeError.json | 3 + ...ettingsOperationsViewProductTypeError.json | 3 + .../SettingsJsonStubs/SettingsConfig.json | 3 + .../SettingsSlidingExpirationConfigError.json | 3 + ...tingsSlidingExpirationConfigTypeError.json | 3 + .../SettingsSlidingExpirationError.json | 3 + ...dingExpirationPushTokenKeepaliveError.json | 3 + ...ExpirationPushTokenKeepaliveTypeError.json | 3 + .../SettingsSlidingExpirationTypeError.json | 3 + .../TtlErrors/SettingsTtlError.json | 3 + .../TtlErrors/SettingsTtlInappsError.json | 3 + .../TtlErrors/SettingsTtlInappsTypeError.json | 3 + .../TtlErrors/SettingsTtlTypeError.json | 3 + .../Network/OperationsURLRoutingTests.swift | 32 ++-------- 37 files changed, 407 insertions(+), 141 deletions(-) create mode 100644 MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesError.json create mode 100644 MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesOperationsError.json create mode 100644 MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesOperationsTypeError.json create mode 100644 MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesTypeError.json diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index 8956fa196..564f2c8ac 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -561,6 +561,10 @@ F34A10462F455C5B0065392A /* SettingsFeatureTogglesError.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A103E2F455C5B0065392A /* SettingsFeatureTogglesError.json */; }; F34A10472F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A10412F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json */; }; F34A10482F455C5B0065392A /* SettingsFeatureTogglesTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A10422F455C5B0065392A /* SettingsFeatureTogglesTypeError.json */; }; + F3BA10552F500A800065392A /* SettingsBaseAddressesError.json in Resources */ = {isa = PBXBuildFile; fileRef = F3BA10512F500A800065392A /* SettingsBaseAddressesError.json */; }; + F3BA10562F500A800065392A /* SettingsBaseAddressesTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = F3BA10522F500A800065392A /* SettingsBaseAddressesTypeError.json */; }; + F3BA10572F500A800065392A /* SettingsBaseAddressesOperationsError.json in Resources */ = {isa = PBXBuildFile; fileRef = F3BA10532F500A800065392A /* SettingsBaseAddressesOperationsError.json */; }; + F3BA10582F500A800065392A /* SettingsBaseAddressesOperationsTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = F3BA10542F500A800065392A /* SettingsBaseAddressesOperationsTypeError.json */; }; F34A45AE2B7628B700634C8B /* MBPushNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = F34A45AD2B7628B700634C8B /* MBPushNotification.swift */; }; F34A45B02B762A6100634C8B /* MindboxPushValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F34A45AF2B762A6100634C8B /* MindboxPushValidator.swift */; }; F351F1C02CE380A40053423E /* InappMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = F351F1BF2CE380A40053423E /* InappMapper.swift */; }; @@ -1290,6 +1294,10 @@ F34A10402F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorMissing.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesShouldSendInAppShowErrorMissing.json; sourceTree = ""; }; F34A10412F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json; sourceTree = ""; }; F34A10422F455C5B0065392A /* SettingsFeatureTogglesTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesTypeError.json; sourceTree = ""; }; + F3BA10512F500A800065392A /* SettingsBaseAddressesError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsBaseAddressesError.json; sourceTree = ""; }; + F3BA10522F500A800065392A /* SettingsBaseAddressesTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsBaseAddressesTypeError.json; sourceTree = ""; }; + F3BA10532F500A800065392A /* SettingsBaseAddressesOperationsError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsBaseAddressesOperationsError.json; sourceTree = ""; }; + F3BA10542F500A800065392A /* SettingsBaseAddressesOperationsTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsBaseAddressesOperationsTypeError.json; sourceTree = ""; }; F34A45AD2B7628B700634C8B /* MBPushNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MBPushNotification.swift; sourceTree = ""; }; F34A45AF2B762A6100634C8B /* MindboxPushValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindboxPushValidator.swift; sourceTree = ""; }; F351F1BF2CE380A40053423E /* InappMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InappMapper.swift; sourceTree = ""; }; @@ -2058,6 +2066,7 @@ 4731A7E72F447C3100CBE1E5 /* SettingsJsonStubs */ = { isa = PBXGroup; children = ( + F3BA10502F500A800065392A /* BaseAddressesError */, F34A10432F455C5B0065392A /* FeatureTogglesError */, 4731A7CB2F447C3100CBE1E5 /* InappError */, 4731A7D92F447C3100CBE1E5 /* OperationsErrors */, @@ -3517,6 +3526,17 @@ path = FeatureTogglesError; sourceTree = ""; }; + F3BA10502F500A800065392A /* BaseAddressesError */ = { + isa = PBXGroup; + children = ( + F3BA10512F500A800065392A /* SettingsBaseAddressesError.json */, + F3BA10522F500A800065392A /* SettingsBaseAddressesTypeError.json */, + F3BA10532F500A800065392A /* SettingsBaseAddressesOperationsError.json */, + F3BA10542F500A800065392A /* SettingsBaseAddressesOperationsTypeError.json */, + ); + path = BaseAddressesError; + sourceTree = ""; + }; F34A45AC2B76286D00634C8B /* PublicModels */ = { isa = PBXGroup; children = ( @@ -4079,6 +4099,10 @@ F34A10462F455C5B0065392A /* SettingsFeatureTogglesError.json in Resources */, F34A10472F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json in Resources */, F34A10482F455C5B0065392A /* SettingsFeatureTogglesTypeError.json in Resources */, + F3BA10552F500A800065392A /* SettingsBaseAddressesError.json in Resources */, + F3BA10562F500A800065392A /* SettingsBaseAddressesTypeError.json in Resources */, + F3BA10572F500A800065392A /* SettingsBaseAddressesOperationsError.json in Resources */, + F3BA10582F500A800065392A /* SettingsBaseAddressesOperationsTypeError.json in Resources */, 4731A7FD2F447C3100CBE1E5 /* InAppDelayTimeTypeError.json in Resources */, 4731A7FE2F447C3100CBE1E5 /* MonitoringLogsTypeError.json in Resources */, 4731A7FF2F447C3100CBE1E5 /* SettingsInAppSettingsMissingMaxInappsPerDay.json in Resources */, diff --git a/Mindbox/InAppMessages/Models/Config/BaseAddressesModel.swift b/Mindbox/InAppMessages/Models/Config/BaseAddressesModel.swift index 16acc0dad..410d2e02c 100644 --- a/Mindbox/InAppMessages/Models/Config/BaseAddressesModel.swift +++ b/Mindbox/InAppMessages/Models/Config/BaseAddressesModel.swift @@ -13,5 +13,16 @@ extension Settings { /// (`/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) } } diff --git a/MindboxTests/ConfigParsing/Settings/SettingsConfigParsingTests.swift b/MindboxTests/ConfigParsing/Settings/SettingsConfigParsingTests.swift index 65879a365..9637445a1 100644 --- a/MindboxTests/ConfigParsing/Settings/SettingsConfigParsingTests.swift +++ b/MindboxTests/ConfigParsing/Settings/SettingsConfigParsingTests.swift @@ -65,6 +65,13 @@ fileprivate enum SettingsConfig: String, Configurable { case settingsInAppSettingsMissingMinIntervalBetweenShows = "SettingsInAppSettingsMissingMinIntervalBetweenShows" // Missing minIntervalBetweenShows case settingsInAppSettingsTypeErrors = "SettingsInAppSettingsTypeErrors" // All parameters have incorrect types + // BaseAddresses file names + + case settingsBaseAddressesError = "SettingsBaseAddressesError" // Key is `baseAddressesTest` instead of `baseAddresses` + case settingsBaseAddressesTypeError = "SettingsBaseAddressesTypeError" // Type of `baseAddresses` is Int instead of BaseAddresses + case settingsBaseAddressesOperationsError = "SettingsBaseAddressesOperationsError" // Key is `operationsTest` instead of `operations` + case settingsBaseAddressesOperationsTypeError = "SettingsBaseAddressesOperationsTypeError" // Type of `operations` is Int instead of String + } final class SettingsConfigParsingTests: XCTestCase { @@ -93,6 +100,9 @@ final class SettingsConfigParsingTests: XCTestCase { XCTAssertNotNil(config.featureToggles, "FeatureToggles must be successfully parsed") XCTAssertEqual(config.featureToggles?.shouldSendInAppShowError, true, "shouldSendInAppShowError must be parsed correctly") + + XCTAssertNotNil(config.baseAddresses, "BaseAddresses must be successfully parsed") + XCTAssertEqual(config.baseAddresses?.operations, "anonymizer-demo-api-regular.mindbox.ru", "operations must be parsed correctly") } // MARK: - Operations @@ -519,15 +529,63 @@ final class SettingsConfigParsingTests: XCTestCase { func test_SettingsConfig_withInAppSettingsTypeErrors_shouldSetAllValuesToNil() { // All parameters have incorrect types let config = try! SettingsConfig.settingsInAppSettingsTypeErrors.getConfig() - + XCTAssertNotNil(config.operations, "Operations must be successfully parsed") XCTAssertNotNil(config.ttl, "TTL must be successfully parsed") XCTAssertNotNil(config.slidingExpiration, "SlidingExpiration must be successfully parsed") - + XCTAssertNil(config.inapp, "InAppSettings must be nil") XCTAssertNil(config.inapp?.maxInappsPerSession, "maxInappsPerSession must be nil due to type error") XCTAssertNil(config.inapp?.maxInappsPerDay, "maxInappsPerDay must be nil due to type error") XCTAssertNil(config.inapp?.minIntervalBetweenShows, "minIntervalBetweenShows must be nil due to type error") } + // MARK: - BaseAddresses + + func test_SettingsConfig_withBaseAddressesError_shouldSetBaseAddressesToNil() { + // Key is `baseAddressesTest` instead of `baseAddresses` + let config = try! SettingsConfig.settingsBaseAddressesError.getConfig() + XCTAssertNil(config.baseAddresses, "BaseAddresses must be `nil` if the key `baseAddresses` is not found") + + XCTAssertNotNil(config.operations, "Operations must be successfully parsed") + XCTAssertNotNil(config.ttl, "TTL must be successfully parsed") + XCTAssertNotNil(config.slidingExpiration, "SlidingExpiration must be successfully parsed") + XCTAssertNotNil(config.inapp, "InAppSettings must be successfully parsed") + XCTAssertNotNil(config.featureToggles, "FeatureToggles must be successfully parsed") + } + + func test_SettingsConfig_withBaseAddressesTypeError_shouldSetBaseAddressesToNil() { + // Type of `baseAddresses` is Int instead of BaseAddresses + let config = try! SettingsConfig.settingsBaseAddressesTypeError.getConfig() + XCTAssertNil(config.baseAddresses, "BaseAddresses must be `nil` if the type of `baseAddresses` is not a `BaseAddresses`") + + XCTAssertNotNil(config.operations, "Operations must be successfully parsed") + XCTAssertNotNil(config.ttl, "TTL must be successfully parsed") + XCTAssertNotNil(config.slidingExpiration, "SlidingExpiration must be successfully parsed") + XCTAssertNotNil(config.inapp, "InAppSettings must be successfully parsed") + XCTAssertNotNil(config.featureToggles, "FeatureToggles must be successfully parsed") + } + + func test_SettingsConfig_withBaseAddressesOperationsError_shouldSetOperationsToNil() { + // Key is `operationsTest` instead of `operations` + let config = try! SettingsConfig.settingsBaseAddressesOperationsError.getConfig() + XCTAssertNotNil(config.baseAddresses, "BaseAddresses must be successfully parsed") + XCTAssertNil(config.baseAddresses?.operations, "operations must be `nil` if the key `operations` is not found") + + XCTAssertNotNil(config.operations, "Operations must be successfully parsed") + XCTAssertNotNil(config.ttl, "TTL must be successfully parsed") + XCTAssertNotNil(config.slidingExpiration, "SlidingExpiration must be successfully parsed") + } + + func test_SettingsConfig_withBaseAddressesOperationsTypeError_shouldSetOperationsToNil() { + // Type of `operations` is Int instead of String + let config = try! SettingsConfig.settingsBaseAddressesOperationsTypeError.getConfig() + XCTAssertNotNil(config.baseAddresses, "BaseAddresses must be successfully parsed") + XCTAssertNil(config.baseAddresses?.operations, "operations must be `nil` if the type of `operations` is not a `String`") + + XCTAssertNotNil(config.operations, "Operations must be successfully parsed") + XCTAssertNotNil(config.ttl, "TTL must be successfully parsed") + XCTAssertNotNil(config.slidingExpiration, "SlidingExpiration must be successfully parsed") + } + } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesError.json new file mode 100644 index 000000000..356641a57 --- /dev/null +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesError.json @@ -0,0 +1,31 @@ +{ + "operations": { + "viewProduct": { + "systemName": "viewProduct" + }, + "viewCategory": { + "systemName": "viewCategory" + }, + "setCart": { + "systemName": "setCart" + } + }, + "ttl": { + "inapps": "1.00:00:00" + }, + "slidingExpiration": { + "config": "0.00:00:23", + "pushTokenKeepalive": "14.00:00:00" + }, + "inapp": { + "maxInappsPerSession": 1, + "maxInappsPerDay": 1, + "minIntervalBetweenShows": "0.00:00:10" + }, + "featureToggles": { + "MobileSdkShouldSendInAppShowError": false + }, + "baseAddressesTest": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" + } +} diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesOperationsError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesOperationsError.json new file mode 100644 index 000000000..f145b4dcf --- /dev/null +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesOperationsError.json @@ -0,0 +1,31 @@ +{ + "operations": { + "viewProduct": { + "systemName": "viewProduct" + }, + "viewCategory": { + "systemName": "viewCategory" + }, + "setCart": { + "systemName": "setCart" + } + }, + "ttl": { + "inapps": "1.00:00:00" + }, + "slidingExpiration": { + "config": "0.00:00:23", + "pushTokenKeepalive": "14.00:00:00" + }, + "inapp": { + "maxInappsPerSession": 1, + "maxInappsPerDay": 1, + "minIntervalBetweenShows": "0.00:00:10" + }, + "featureToggles": { + "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operationsTest": "anonymizer-demo-api-regular.mindbox.ru" + } +} diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesOperationsTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesOperationsTypeError.json new file mode 100644 index 000000000..7220a111d --- /dev/null +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesOperationsTypeError.json @@ -0,0 +1,31 @@ +{ + "operations": { + "viewProduct": { + "systemName": "viewProduct" + }, + "viewCategory": { + "systemName": "viewCategory" + }, + "setCart": { + "systemName": "setCart" + } + }, + "ttl": { + "inapps": "1.00:00:00" + }, + "slidingExpiration": { + "config": "0.00:00:23", + "pushTokenKeepalive": "14.00:00:00" + }, + "inapp": { + "maxInappsPerSession": 1, + "maxInappsPerDay": 1, + "minIntervalBetweenShows": "0.00:00:10" + }, + "featureToggles": { + "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": 123 + } +} diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesTypeError.json new file mode 100644 index 000000000..8eae125af --- /dev/null +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesTypeError.json @@ -0,0 +1,29 @@ +{ + "operations": { + "viewProduct": { + "systemName": "viewProduct" + }, + "viewCategory": { + "systemName": "viewCategory" + }, + "setCart": { + "systemName": "setCart" + } + }, + "ttl": { + "inapps": "1.00:00:00" + }, + "slidingExpiration": { + "config": "0.00:00:23", + "pushTokenKeepalive": "14.00:00:00" + }, + "inapp": { + "maxInappsPerSession": 1, + "maxInappsPerDay": 1, + "minIntervalBetweenShows": "0.00:00:10" + }, + "featureToggles": { + "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": 123 +} diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesError.json index 333dfadae..c71f30eb9 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesError.json @@ -1,28 +1,28 @@ { - "operations": { - "viewProduct": { - "systemName": "ProductView" - }, - "viewCategory": { - "systemName": "CategoryView" - }, - "setCart": { - "systemName": "SetCart" - } + "operations": { + "viewProduct": { + "systemName": "ProductView" }, - "ttl": { - "inapps": "0.00:00:10" + "viewCategory": { + "systemName": "CategoryView" }, - "slidingExpiration": { - "config": "0.00:00:10", - "pushTokenKeepalive": "0.00:00:10" - }, - "inapp": { - "maxInappsPerSession": 1, - "maxInappsPerDay": 1, - "minIntervalBetweenShows": "0.00:00:10" - }, - "featureTogglesTest": { - "MobileSdkShouldSendInAppShowError": true + "setCart": { + "systemName": "SetCart" } + }, + "ttl": { + "inapps": "0.00:00:10" + }, + "slidingExpiration": { + "config": "0.00:00:10", + "pushTokenKeepalive": "0.00:00:10" + }, + "inapp": { + "maxInappsPerSession": 1, + "maxInappsPerDay": 1, + "minIntervalBetweenShows": "0.00:00:10" + }, + "featureTogglesTest": { + "MobileSdkShouldSendInAppShowError": true + } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorFalse.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorFalse.json index 119f59394..2e49e6cd9 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorFalse.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorFalse.json @@ -1,28 +1,28 @@ { - "operations": { - "viewProduct": { - "systemName": "ProductView" - }, - "viewCategory": { - "systemName": "CategoryView" - }, - "setCart": { - "systemName": "SetCart" - } + "operations": { + "viewProduct": { + "systemName": "ProductView" }, - "ttl": { - "inapps": "0.00:00:10" + "viewCategory": { + "systemName": "CategoryView" }, - "slidingExpiration": { - "config": "0.00:00:10", - "pushTokenKeepalive": "0.00:00:10" - }, - "inapp": { - "maxInappsPerSession": 1, - "maxInappsPerDay": 1, - "minIntervalBetweenShows": "0.00:00:10" - }, - "featureToggles": { - "MobileSdkShouldSendInAppShowError": false + "setCart": { + "systemName": "SetCart" } + }, + "ttl": { + "inapps": "0.00:00:10" + }, + "slidingExpiration": { + "config": "0.00:00:10", + "pushTokenKeepalive": "0.00:00:10" + }, + "inapp": { + "maxInappsPerSession": 1, + "maxInappsPerDay": 1, + "minIntervalBetweenShows": "0.00:00:10" + }, + "featureToggles": { + "MobileSdkShouldSendInAppShowError": false + } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorMissing.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorMissing.json index 7d8532fce..22ad2bb27 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorMissing.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorMissing.json @@ -1,27 +1,26 @@ { - "operations": { - "viewProduct": { - "systemName": "ProductView" - }, - "viewCategory": { - "systemName": "CategoryView" - }, - "setCart": { - "systemName": "SetCart" - } + "operations": { + "viewProduct": { + "systemName": "ProductView" }, - "ttl": { - "inapps": "0.00:00:10" + "viewCategory": { + "systemName": "CategoryView" }, - "slidingExpiration": { - "config": "0.00:00:10", - "pushTokenKeepalive": "0.00:00:10" - }, - "inapp": { - "maxInappsPerSession": 1, - "maxInappsPerDay": 1, - "minIntervalBetweenShows": "0.00:00:10" - }, - "featureToggles": { + "setCart": { + "systemName": "SetCart" } + }, + "ttl": { + "inapps": "0.00:00:10" + }, + "slidingExpiration": { + "config": "0.00:00:10", + "pushTokenKeepalive": "0.00:00:10" + }, + "inapp": { + "maxInappsPerSession": 1, + "maxInappsPerDay": 1, + "minIntervalBetweenShows": "0.00:00:10" + }, + "featureToggles": {} } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json index f8beeba06..962e6fcc4 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json @@ -1,28 +1,28 @@ { - "operations": { - "viewProduct": { - "systemName": "ProductView" - }, - "viewCategory": { - "systemName": "CategoryView" - }, - "setCart": { - "systemName": "SetCart" - } + "operations": { + "viewProduct": { + "systemName": "ProductView" }, - "ttl": { - "inapps": "0.00:00:10" + "viewCategory": { + "systemName": "CategoryView" }, - "slidingExpiration": { - "config": "0.00:00:10", - "pushTokenKeepalive": "0.00:00:10" - }, - "inapp": { - "maxInappsPerSession": 1, - "maxInappsPerDay": 1, - "minIntervalBetweenShows": "0.00:00:10" - }, - "featureToggles": { - "MobileSdkShouldSendInAppShowError": "yes" + "setCart": { + "systemName": "SetCart" } + }, + "ttl": { + "inapps": "0.00:00:10" + }, + "slidingExpiration": { + "config": "0.00:00:10", + "pushTokenKeepalive": "0.00:00:10" + }, + "inapp": { + "maxInappsPerSession": 1, + "maxInappsPerDay": 1, + "minIntervalBetweenShows": "0.00:00:10" + }, + "featureToggles": { + "MobileSdkShouldSendInAppShowError": "yes" + } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesTypeError.json index 2be0abea5..5f7534e0f 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesTypeError.json @@ -1,26 +1,26 @@ { - "operations": { - "viewProduct": { - "systemName": "ProductView" - }, - "viewCategory": { - "systemName": "CategoryView" - }, - "setCart": { - "systemName": "SetCart" - } + "operations": { + "viewProduct": { + "systemName": "ProductView" }, - "ttl": { - "inapps": "0.00:00:10" + "viewCategory": { + "systemName": "CategoryView" }, - "slidingExpiration": { - "config": "0.00:00:10", - "pushTokenKeepalive": "0.00:00:10" - }, - "inapp": { - "maxInappsPerSession": 1, - "maxInappsPerDay": 1, - "minIntervalBetweenShows": "0.00:00:10" - }, - "featureToggles": 123 + "setCart": { + "systemName": "SetCart" + } + }, + "ttl": { + "inapps": "0.00:00:10" + }, + "slidingExpiration": { + "config": "0.00:00:10", + "pushTokenKeepalive": "0.00:00:10" + }, + "inapp": { + "maxInappsPerSession": 1, + "maxInappsPerDay": 1, + "minIntervalBetweenShows": "0.00:00:10" + }, + "featureToggles": 123 } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsAllOperationsWithErrors.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsAllOperationsWithErrors.json index e2e0d7432..4f9f1c8c0 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsAllOperationsWithErrors.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsAllOperationsWithErrors.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsAllOperationsWithTypeErrors.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsAllOperationsWithTypeErrors.json index 2869f6137..2e84f20d9 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsAllOperationsWithTypeErrors.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsAllOperationsWithTypeErrors.json @@ -18,5 +18,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsError.json index 850a1020c..b3190668f 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsTypeError.json index 164da9a07..33325f1cd 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsTypeError.json @@ -14,5 +14,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartError.json index ee80de43f..85de3c662 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameError.json index f06b175e7..19750a703 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameMixedError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameMixedError.json index 0ebcc88ba..7b68e495b 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameMixedError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameMixedError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameTypeError.json index 2a8e4cf03..7688cda6a 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameTypeError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartTypeError.json index 0f6f28c8a..1a5fd4d2d 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartTypeError.json @@ -20,5 +20,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductError.json index 1588843d6..d9e492797 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductSystemNameError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductSystemNameError.json index b5d2fc609..77b7561a6 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductSystemNameError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductSystemNameError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductSystemNameTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductSystemNameTypeError.json index e32116ea9..b9428f212 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductSystemNameTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductSystemNameTypeError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductTypeError.json index 232f4c94a..6e6cde150 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductTypeError.json @@ -22,5 +22,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SettingsConfig.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SettingsConfig.json index c03b23c2f..67893c3ad 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SettingsConfig.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SettingsConfig.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": true + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationConfigError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationConfigError.json index 335c80084..b30a726ed 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationConfigError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationConfigError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationConfigTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationConfigTypeError.json index 312cc0253..02849935a 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationConfigTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationConfigTypeError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationError.json index 9afa8c0a8..9e98f807c 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationPushTokenKeepaliveError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationPushTokenKeepaliveError.json index d8132522c..17a8fc3df 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationPushTokenKeepaliveError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationPushTokenKeepaliveError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationPushTokenKeepaliveTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationPushTokenKeepaliveTypeError.json index 577808a64..0aa6c7ed4 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationPushTokenKeepaliveTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationPushTokenKeepaliveTypeError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationTypeError.json index 64c11af8f..56e48d587 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationTypeError.json @@ -21,5 +21,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlError.json index fbe1538d5..84285bb3c 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlInappsError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlInappsError.json index b6da818a9..56ae13299 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlInappsError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlInappsError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlInappsTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlInappsTypeError.json index 6b99a2362..f4c2f5087 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlInappsTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlInappsTypeError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlTypeError.json index 9d5422fc6..e21788dc6 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlTypeError.json @@ -22,5 +22,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/Network/OperationsURLRoutingTests.swift b/MindboxTests/Network/OperationsURLRoutingTests.swift index 2a18103ab..897c85893 100644 --- a/MindboxTests/Network/OperationsURLRoutingTests.swift +++ b/MindboxTests/Network/OperationsURLRoutingTests.swift @@ -108,32 +108,12 @@ struct OperationsURLRoutingTests { } } - // MARK: - Config JSON decoding (backend schema preserves `baseAddresses.operations`) - - @Test("Settings decodes baseAddresses.operations", .tags(.decoding)) - func settingsDecodesBaseAddresses() throws { - let json = """ - { - "settings": { - "baseAddresses": { "operations": "anonymizer.client.ru" }, - "ttl": { "inapps": "1.00:00:00" } - } - } - """.data(using: .utf8)! - - let response = try JSONDecoder().decode(ConfigResponse.self, from: json) - #expect(response.settings?.baseAddresses?.operations == "anonymizer.client.ru") - } - - @Test("Settings tolerates missing baseAddresses", .tags(.decoding)) - func settingsWithoutBaseAddresses() throws { - let json = """ - { "settings": { "ttl": { "inapps": "1.00:00:00" } } } - """.data(using: .utf8)! - - let response = try JSONDecoder().decode(ConfigResponse.self, from: json) - #expect(response.settings?.baseAddresses == nil) - } + // MARK: - Rollback signals from JSON config + // + // Happy-path and key/type errors live in `SettingsConfigParsingTests` + // (driven by the canonical `pkl-mobile-config` stubs). The two cases + // below stay here because they exercise the rollback channel that's + // specific to this feature and not modeled in the Pkl error stubs. @Test("Settings decodes explicit null as rollback signal", .tags(.decoding)) func settingsDecodesNullOperationsAsRollback() throws { From 8fb075e2b1c2b49874234cb9b47c83da5204097e Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:30:12 +0300 Subject: [PATCH 08/24] MOBILE-130: Centralize MBConfiguration tests and expand coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `MindboxTests/Configuration/MBConfigurationTests.swift` (Swift Testing, `.mbConfiguration` tag) — a single suite covering the full behavior of `MBConfiguration`: programmatic init validation (`domain`, `endpoint`, `operationsDomain`, UUID handling for `previousInstallationId`/`previousDeviceUUID`), defaults, plist init, Codable (legacy / new / decoder validation), and `ConfigValidation.compare` across the whole input space (identity, `nil` handling, `.rest`-affecting fields, `.shouldCreateCustomer` priority, fields that must not trigger any change). Consolidates from two sources: - Plist init coverage from `MBConfigurationTestCase.swift` (XCTest, deleted) — folded into a single parametrised section. - Seven `MBConfiguration`-shaped tests previously living in `OperationsURLRoutingTests.swift` (init validation, two `ConfigValidation.compare` cases, legacy JSON decoding). Adds `Tag.mbConfiguration` and registers the new file under a new `Configuration/` group in `Mindbox.xcodeproj/project.pbxproj`. --- Mindbox.xcodeproj/project.pbxproj | 16 +- .../Configuration/MBConfigurationTests.swift | 372 ++++++++++++++++++ MindboxTests/Extensions/Tag+Extensions.swift | 1 + MindboxTests/MBConfigurationTestCase.swift | 53 --- .../Network/OperationsURLRoutingTests.swift | 89 ----- 5 files changed, 385 insertions(+), 146 deletions(-) create mode 100644 MindboxTests/Configuration/MBConfigurationTests.swift delete mode 100644 MindboxTests/MBConfigurationTestCase.swift diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index 564f2c8ac..00671529e 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -28,7 +28,7 @@ 31A20D4E25B6EFB600AAA0A3 /* MindboxDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31A20D4D25B6EFB600AAA0A3 /* MindboxDelegate.swift */; }; 31EB907325C402F900368FFB /* TestConfig3.plist in Resources */ = {isa = PBXBuildFile; fileRef = 31EB907125C402F900368FFB /* TestConfig3.plist */; }; 31EB907425C402F900368FFB /* TestConfig2.plist in Resources */ = {isa = PBXBuildFile; fileRef = 31EB907225C402F900368FFB /* TestConfig2.plist */; }; - 31ED2DEC25C444C400301FAD /* MBConfigurationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31ED2DEB25C444C400301FAD /* MBConfigurationTestCase.swift */; }; + F3CD20262F600A800065392A /* MBConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CD20272F600A800065392A /* MBConfigurationTests.swift */; }; 31ED2DF225C4456600301FAD /* TestConfig_Invalid_2.plist in Resources */ = {isa = PBXBuildFile; fileRef = 31ED2DEF25C4456600301FAD /* TestConfig_Invalid_2.plist */; }; 31ED2DF325C4456600301FAD /* TestConfig_Invalid_1.plist in Resources */ = {isa = PBXBuildFile; fileRef = 31ED2DF025C4456600301FAD /* TestConfig_Invalid_1.plist */; }; 31ED2DF425C4456600301FAD /* TestConfig_Invalid_3.plist in Resources */ = {isa = PBXBuildFile; fileRef = 31ED2DF125C4456600301FAD /* TestConfig_Invalid_3.plist */; }; @@ -757,7 +757,7 @@ 31A20D4D25B6EFB600AAA0A3 /* MindboxDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindboxDelegate.swift; sourceTree = ""; }; 31EB907125C402F900368FFB /* TestConfig3.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig3.plist; sourceTree = ""; }; 31EB907225C402F900368FFB /* TestConfig2.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig2.plist; sourceTree = ""; }; - 31ED2DEB25C444C400301FAD /* MBConfigurationTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MBConfigurationTestCase.swift; sourceTree = ""; }; + F3CD20272F600A800065392A /* MBConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MBConfigurationTests.swift; sourceTree = ""; }; 31ED2DEF25C4456600301FAD /* TestConfig_Invalid_2.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig_Invalid_2.plist; sourceTree = ""; }; 31ED2DF025C4456600301FAD /* TestConfig_Invalid_1.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig_Invalid_1.plist; sourceTree = ""; }; 31ED2DF125C4456600301FAD /* TestConfig_Invalid_3.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig_Invalid_3.plist; sourceTree = ""; }; @@ -1624,7 +1624,7 @@ 84FCD3B325CA0FD300D1E574 /* Mock */, 84B625F525C98EE000AB6228 /* DI */, 84B625EE25C98A8000AB6228 /* Validators */, - 31ED2DEB25C444C400301FAD /* MBConfigurationTestCase.swift */, + F3CD20282F600A800065392A /* Configuration */, 313B233E25ADEA0F00A1CB72 /* MindboxTests.swift */, 84DC49D525D185A600D5D758 /* Supporting Files */, 313B234025ADEA0F00A1CB72 /* Info.plist */, @@ -3526,6 +3526,14 @@ path = FeatureTogglesError; sourceTree = ""; }; + F3CD20282F600A800065392A /* Configuration */ = { + isa = PBXGroup; + children = ( + F3CD20272F600A800065392A /* MBConfigurationTests.swift */, + ); + path = Configuration; + sourceTree = ""; + }; F3BA10502F500A800065392A /* BaseAddressesError */ = { isa = PBXGroup; children = ( @@ -4709,7 +4717,7 @@ 313B233F25ADEA0F00A1CB72 /* MindboxTests.swift in Sources */, F39116EE2AA53EE400852298 /* VariantImageUrlExtractorServiceTests.swift in Sources */, A154E334299E110E00F8F074 /* EventRepositoryMock.swift in Sources */, - 31ED2DEC25C444C400301FAD /* MBConfigurationTestCase.swift in Sources */, + F3CD20262F600A800065392A /* MBConfigurationTests.swift in Sources */, F3D925AD2A1236F400135C87 /* URLSessionImageDownloaderTests.swift in Sources */, 4741DAC42E85C49F00EB2497 /* DatabaseLoaderFlowTests.swift in Sources */, F3A8B9A02A3A52F400E9C055 /* ABTestValidatorTests.swift in Sources */, diff --git a/MindboxTests/Configuration/MBConfigurationTests.swift b/MindboxTests/Configuration/MBConfigurationTests.swift new file mode 100644 index 000000000..bde35779a --- /dev/null +++ b/MindboxTests/Configuration/MBConfigurationTests.swift @@ -0,0 +1,372 @@ +// +// MBConfigurationTests.swift +// MindboxTests +// +// Created by Sergei Semko on 4/27/26. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +import Testing +@testable import Mindbox + +@Suite("MBConfiguration", .tags(.mbConfiguration)) +struct MBConfigurationTests { + + private let domain = "api.mindbox.ru" + private let endpoint = "test-endpoint" + private let validUUID = "F47AC10B-58CC-4372-A567-0E02B2C3D479" + + // MARK: - Init: domain validation + + @Test("Valid domain is accepted") + func validDomainAccepted() throws { + let config = try MBConfiguration(endpoint: endpoint, domain: domain) + #expect(config.domain == domain) + } + + @Test("Empty domain throws") + func emptyDomainThrows() { + #expect(throws: MindboxError.self) { + _ = try MBConfiguration(endpoint: endpoint, domain: "") + } + } + + @Test("Domain with whitespace throws") + func domainWithWhitespaceThrows() { + #expect(throws: MindboxError.self) { + _ = try MBConfiguration(endpoint: endpoint, domain: "api mindbox ru") + } + } + + @Test("Domain with embedded scheme throws") + func domainWithSchemeThrows() { + #expect(throws: MindboxError.self) { + _ = try MBConfiguration(endpoint: endpoint, domain: "https://api.mindbox.ru") + } + } + + // MARK: - Init: endpoint validation + + @Test("Empty endpoint throws") + func emptyEndpointThrows() { + #expect(throws: MindboxError.self) { + _ = try MBConfiguration(endpoint: "", domain: domain) + } + } + + // MARK: - Init: operationsDomain validation + + @Test("nil operationsDomain stored as nil") + func nilOperationsDomainStoredAsNil() throws { + let config = try MBConfiguration(endpoint: endpoint, domain: domain) + #expect(config.operationsDomain == nil) + } + + @Test("Valid operationsDomain stored as-is") + func validOperationsDomainStored() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "anonymizer.client.ru" + ) + #expect(config.operationsDomain == "anonymizer.client.ru") + } + + @Test("Empty operationsDomain treated as nil (not throw)") + func emptyOperationsDomainTreatedAsNil() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "" + ) + #expect(config.operationsDomain == nil) + } + + @Test("Invalid operationsDomain throws") + func invalidOperationsDomainThrows() { + #expect(throws: MindboxError.self) { + _ = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "not a host with spaces" + ) + } + } + + // MARK: - Init: previousInstallationId / previousDeviceUUID UUID handling + + @Test("Valid previousInstallationId UUID is stored") + func validPreviousInstallationIdStored() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + previousInstallationId: validUUID + ) + #expect(config.previousInstallationId == validUUID) + } + + @Test("Invalid previousInstallationId is silently coerced to empty string") + func invalidPreviousInstallationIdCoercedToEmpty() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + previousInstallationId: "not-a-uuid" + ) + #expect(config.previousInstallationId == "") + } + + @Test("Empty previousInstallationId stays nil") + func emptyPreviousInstallationIdStaysNil() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + previousInstallationId: "" + ) + #expect(config.previousInstallationId == nil) + } + + @Test("Valid previousDeviceUUID is stored") + func validPreviousDeviceUUIDStored() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + previousDeviceUUID: validUUID + ) + #expect(config.previousDeviceUUID == validUUID) + } + + @Test("Invalid previousDeviceUUID is silently coerced to empty string") + func invalidPreviousDeviceUUIDCoercedToEmpty() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + previousDeviceUUID: "not-a-uuid" + ) + #expect(config.previousDeviceUUID == "") + } + + // MARK: - Init: defaults + + @Test("Default values match documented public API") + func defaultValues() throws { + let config = try MBConfiguration(endpoint: endpoint, domain: domain) + #expect(config.subscribeCustomerIfCreated == false) + #expect(config.shouldCreateCustomer == true) + #expect(config.imageLoadingMaxTimeInSeconds == nil) + #expect(config.previousInstallationId == nil) + #expect(config.previousDeviceUUID == nil) + #expect(config.operationsDomain == nil) + } + + // MARK: - Init: plist + + @Test("Plist init succeeds for valid configurations", .tags(.decoding)) + func plistInitSucceedsForValidConfigs() throws { + // TestConfig1/2/3 — full valid configurations. + // TestConfig_Invalid_3/4 — valid despite the filename (only previousIDs / domain + // edges are checked at the type level, not the file). + for plist in ["TestConfig1", "TestConfig2", "TestConfig3", "TestConfig_Invalid_3", "TestConfig_Invalid_4"] { + #expect(throws: Never.self) { try MBConfiguration(plistName: plist) } + } + } + + @Test("Plist init throws on empty domain or endpoint", .tags(.decoding)) + func plistInitThrowsOnInvalid() { + // TestConfig_Invalid_1 — empty domain. TestConfig_Invalid_2 — empty endpoint. + for plist in ["TestConfig_Invalid_1", "TestConfig_Invalid_2"] { + #expect(throws: (any Error).self) { try MBConfiguration(plistName: plist) } + } + } + + @Test("Plist init throws on missing file") + func plistInitThrowsOnMissingFile() { + #expect(throws: (any Error).self) { + try MBConfiguration(plistName: "definitely-does-not-exist") + } + } + + // MARK: - Codable + + @Test("Decodes legacy JSON without operationsDomain key", .tags(.decoding)) + func decodesLegacyJSONWithoutOperationsDomain() throws { + let legacyJSON = """ + { + "endpoint": "app-IOS", + "domain": "api.mindbox.ru", + "subscribeCustomerIfCreated": false, + "shouldCreateCustomer": true, + "uuidDebugEnabled": true + } + """.data(using: .utf8)! + + let config = try JSONDecoder().decode(MBConfiguration.self, from: legacyJSON) + #expect(config.endpoint == "app-IOS") + #expect(config.domain == "api.mindbox.ru") + #expect(config.operationsDomain == nil) + } + + @Test("Decodes JSON with operationsDomain", .tags(.decoding)) + func decodesJSONWithOperationsDomain() throws { + let json = """ + { + "endpoint": "app-IOS", + "domain": "api.mindbox.ru", + "operationsDomain": "anonymizer.client.ru" + } + """.data(using: .utf8)! + + let config = try JSONDecoder().decode(MBConfiguration.self, from: json) + #expect(config.operationsDomain == "anonymizer.client.ru") + } + + @Test("Decoder applies the same validation as the programmatic init", .tags(.decoding)) + func decoderEnforcesValidation() { + let invalid = """ + { "endpoint": "", "domain": "api.mindbox.ru" } + """.data(using: .utf8)! + + #expect(throws: (any Error).self) { + _ = try JSONDecoder().decode(MBConfiguration.self, from: invalid) + } + } + + // MARK: - ConfigValidation.compare — identity / nil handling + + @Test("Identical configs → none") + func identicalConfigsReturnNone() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: domain) + let rhs = try MBConfiguration(endpoint: endpoint, domain: domain) + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .none) + } + + @Test("Both sides nil → none") + func bothNilReturnNone() { + var validation = ConfigValidation() + validation.compare(nil, nil) + #expect(validation.changedState == .none) + } + + @Test("nil vs configured → rest (first init counts as REST change)") + func nilToConfiguredReturnsRest() throws { + let rhs = try MBConfiguration(endpoint: endpoint, domain: domain) + var validation = ConfigValidation() + validation.compare(nil, rhs) + #expect(validation.changedState == .rest) + } + + @Test("configured vs nil → rest") + func configuredToNilReturnsRest() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: domain) + var validation = ConfigValidation() + validation.compare(lhs, nil) + #expect(validation.changedState == .rest) + } + + // MARK: - ConfigValidation.compare — REST-affecting fields + + @Test("Domain change → rest") + func domainChangeReturnsRest() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: "a.mindbox.ru") + let rhs = try MBConfiguration(endpoint: endpoint, domain: "b.mindbox.ru") + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .rest) + } + + @Test("Endpoint change → rest") + func endpointChangeReturnsRest() throws { + let lhs = try MBConfiguration(endpoint: "endpoint-A", domain: domain) + let rhs = try MBConfiguration(endpoint: "endpoint-B", domain: domain) + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .rest) + } + + @Test("Domain and endpoint both change → rest (single classification)") + func bothRestFieldsChangeReturnRest() throws { + let lhs = try MBConfiguration(endpoint: "endpoint-A", domain: "a.mindbox.ru") + let rhs = try MBConfiguration(endpoint: "endpoint-B", domain: "b.mindbox.ru") + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .rest) + } + + // MARK: - ConfigValidation.compare — shouldCreateCustomer + + @Test("shouldCreateCustomer change → shouldCreateCustomer") + func shouldCreateCustomerChangeReturnsShouldCreateCustomer() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: domain, shouldCreateCustomer: true) + let rhs = try MBConfiguration(endpoint: endpoint, domain: domain, shouldCreateCustomer: false) + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .shouldCreateCustomer) + } + + @Test("rest change wins over shouldCreateCustomer change (priority)") + func restWinsOverShouldCreateCustomer() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: "a.mindbox.ru", shouldCreateCustomer: true) + let rhs = try MBConfiguration(endpoint: endpoint, domain: "b.mindbox.ru", shouldCreateCustomer: false) + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .rest) + } + + // MARK: - ConfigValidation.compare — fields that must NOT trigger any change + + @Test("operationsDomain change → none (new value applies without re-install)") + func operationsDomainChangeReturnsNone() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: domain, operationsDomain: "old.client.ru") + let rhs = try MBConfiguration(endpoint: endpoint, domain: domain, operationsDomain: "new.client.ru") + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .none) + } + + @Test("subscribeCustomerIfCreated change → none") + func subscribeCustomerIfCreatedChangeReturnsNone() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: domain, subscribeCustomerIfCreated: false) + let rhs = try MBConfiguration(endpoint: endpoint, domain: domain, subscribeCustomerIfCreated: true) + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .none) + } + + @Test("previousInstallationId change → none") + func previousInstallationIdChangeReturnsNone() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: domain) + let rhs = try MBConfiguration(endpoint: endpoint, domain: domain, previousInstallationId: validUUID) + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .none) + } + + @Test("previousDeviceUUID change → none") + func previousDeviceUUIDChangeReturnsNone() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: domain) + let rhs = try MBConfiguration(endpoint: endpoint, domain: domain, previousDeviceUUID: validUUID) + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .none) + } + + @Test("imageLoadingMaxTimeInSeconds change → none") + func imageLoadingMaxTimeInSecondsChangeReturnsNone() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: domain, imageLoadingMaxTimeInSeconds: 5) + let rhs = try MBConfiguration(endpoint: endpoint, domain: domain, imageLoadingMaxTimeInSeconds: 10) + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .none) + } + + @Test("uuidDebugEnabled change → none") + func uuidDebugEnabledChangeReturnsNone() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: domain, uuidDebugEnabled: true) + let rhs = try MBConfiguration(endpoint: endpoint, domain: domain, uuidDebugEnabled: false) + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .none) + } +} diff --git a/MindboxTests/Extensions/Tag+Extensions.swift b/MindboxTests/Extensions/Tag+Extensions.swift index 0cd6600d6..f9381ded6 100644 --- a/MindboxTests/Extensions/Tag+Extensions.swift +++ b/MindboxTests/Extensions/Tag+Extensions.swift @@ -26,4 +26,5 @@ extension Tag { @Tag static var webView: Self @Tag static var trackVisit: Self @Tag static var operationsRouting: Self + @Tag static var mbConfiguration: Self } diff --git a/MindboxTests/MBConfigurationTestCase.swift b/MindboxTests/MBConfigurationTestCase.swift deleted file mode 100644 index b3f88629f..000000000 --- a/MindboxTests/MBConfigurationTestCase.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// MBConfigurationTest.swift -// MindboxTests -// -// Created by Mikhail Barilov on 29.01.2021. -// Copyright © 2021 Mindbox. All rights reserved. -// - -import XCTest -@testable import Mindbox - -class MBConfigurationTestCase: XCTestCase { - // Invalid - let emptyDomainFile = "TestConfig_Invalid_1" - let emptyEndpointFile = "TestConfig_Invalid_2" - // Valid - let emptyUUIDFile = "TestConfig_Invalid_3" - let emptyIDDomainFile = "TestConfig_Invalid_4" - - override func setUpWithError() throws { - } - - override func tearDownWithError() throws { - } - - func test_MBConfiguration_should_not_throw() throws { - try [ - emptyUUIDFile, - emptyIDDomainFile - ].forEach { file in - XCTAssertNoThrow(try MBConfiguration(plistName: file), "") - } - } - - func test_MBConfiguration_should_throw() throws { - try [ - emptyDomainFile, - emptyEndpointFile - ].forEach { file in - XCTAssertThrowsError(try MBConfiguration(plistName: file), "") { error in - if let localizedError = error as? LocalizedError { - XCTAssertNotNil(localizedError.errorDescription) - XCTAssertNotNil(localizedError.failureReason) - } - } - } - - XCTAssertNotNil(try? MBConfiguration(plistName: "TestConfig1")) - XCTAssertNotNil(try? MBConfiguration(plistName: "TestConfig2")) - XCTAssertNotNil(try? MBConfiguration(plistName: "TestConfig3")) - XCTAssertNil(try? MBConfiguration(plistName: "file_that_|never_exist№%:,.;()(;.,:%№")) - } -} diff --git a/MindboxTests/Network/OperationsURLRoutingTests.swift b/MindboxTests/Network/OperationsURLRoutingTests.swift index 897c85893..173065189 100644 --- a/MindboxTests/Network/OperationsURLRoutingTests.swift +++ b/MindboxTests/Network/OperationsURLRoutingTests.swift @@ -69,45 +69,6 @@ struct OperationsURLRoutingTests { #expect(url?.query?.contains("endpointId=test-endpoint") == true) } - // MARK: - MBConfiguration validation - - @Test("MBConfiguration accepts nil operationsDomain (backwards compatible)") - func configAcceptsNilOperationsDomain() throws { - let config = try MBConfiguration(endpoint: "e", domain: domain) - #expect(config.operationsDomain == nil) - } - - @Test("MBConfiguration accepts valid operationsDomain") - func configAcceptsValidOperationsDomain() throws { - let config = try MBConfiguration( - endpoint: "e", - domain: domain, - operationsDomain: opsHost - ) - #expect(config.operationsDomain == opsHost) - } - - @Test("MBConfiguration treats empty operationsDomain as nil") - func configTreatsEmptyOperationsDomainAsNil() throws { - let config = try MBConfiguration( - endpoint: "e", - domain: domain, - operationsDomain: "" - ) - #expect(config.operationsDomain == nil) - } - - @Test("MBConfiguration rejects invalid operationsDomain") - func configRejectsInvalidOperationsDomain() { - #expect(throws: MindboxError.self) { - _ = try MBConfiguration( - endpoint: "e", - domain: domain, - operationsDomain: "not a host with spaces" - ) - } - } - // MARK: - Rollback signals from JSON config // // Happy-path and key/type errors live in `SettingsConfigParsingTests` @@ -136,36 +97,6 @@ struct OperationsURLRoutingTests { #expect(response.settings?.baseAddresses?.operations == "") } - // MARK: - ConfigValidation.compare - - @Test("ConfigValidation does NOT flag operationsDomain change — new value applies without re-install") - func configValidationIgnoresOperationsDomainChange() throws { - let lhs = try MBConfiguration( - endpoint: "e", - domain: domain, - operationsDomain: "old.client.ru" - ) - let rhs = try MBConfiguration( - endpoint: "e", - domain: domain, - operationsDomain: "new.client.ru" - ) - var validation = ConfigValidation() - validation.compare(lhs, rhs) - #expect(validation.changedState == .none) - } - - @Test("ConfigValidation still flags domain change as REST (operationsDomain unaffected)") - func configValidationDetectsDomainChange() throws { - let lhs = try MBConfiguration(endpoint: "e", domain: "a.mindbox.ru", - operationsDomain: opsHost) - let rhs = try MBConfiguration(endpoint: "e", domain: "b.mindbox.ru", - operationsDomain: opsHost) - var validation = ConfigValidation() - validation.compare(lhs, rhs) - #expect(validation.changedState == .rest) - } - // MARK: - Priority resolution (MBNetworkFetcher) @Test("Priority — JSON wins when both JSON and init are set") @@ -275,26 +206,6 @@ struct OperationsURLRoutingTests { #expect(storage.operationsDomainFromConfig == nil) } - // MARK: - Backwards compatibility - - @Test("MBConfiguration decodes legacy JSON without operationsDomain key", .tags(.decoding)) - func decodesLegacyConfigWithoutOperationsDomain() throws { - let legacyJSON = """ - { - "endpoint": "app-IOS", - "domain": "api.mindbox.ru", - "subscribeCustomerIfCreated": false, - "shouldCreateCustomer": true, - "uuidDebugEnabled": true - } - """.data(using: .utf8)! - - let config = try JSONDecoder().decode(MBConfiguration.self, from: legacyJSON) - #expect(config.endpoint == "app-IOS") - #expect(config.domain == "api.mindbox.ru") - #expect(config.operationsDomain == nil) - } - // MARK: - Helpers private static func makeEventWrapper( From 82c667bf6d724d32871595d34be19e0dd4c024e4 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:42:46 +0300 Subject: [PATCH 09/24] MOBILE-130: Drop dead SDKLogsRoute and use the live path in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SDKLogsRoute` had no references in production code: SDK logs flow through `SDKLogsManager.sendLogs` → `EventRepository.send` → `EventRoute.asyncEvent` (`MBEventRepository.makeRoute` for `.sdkLogs`). Removes the type from `SDKLogsRequest.swift`. `SDKLogsRequest` (the Codable body) stays — it is the request payload encoded into `Event.body`. Updates `OperationsURLRoutingTests` to assert routing for `.sdkLogs` through the actual `EventRoute.asyncEvent` path. The dedicated `sdkLogsRouteUsesOperationsDomain` test is removed (subsumed by the extended `eventRoutesUseOperationsDomain` and `noOperationsDomainFallsBackToDomain` cases). --- Mindbox/MindboxLogger/SDKLogsRequest.swift | 17 ----------------- .../Network/OperationsURLRoutingTests.swift | 12 +++++------- 2 files changed, 5 insertions(+), 24 deletions(-) diff --git a/Mindbox/MindboxLogger/SDKLogsRequest.swift b/Mindbox/MindboxLogger/SDKLogsRequest.swift index a952d9930..7530eaf08 100644 --- a/Mindbox/MindboxLogger/SDKLogsRequest.swift +++ b/Mindbox/MindboxLogger/SDKLogsRequest.swift @@ -12,20 +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? - var baseURLKind: RouteBaseURL { .operations } - - 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] - } -} diff --git a/MindboxTests/Network/OperationsURLRoutingTests.swift b/MindboxTests/Network/OperationsURLRoutingTests.swift index 173065189..368fa9d89 100644 --- a/MindboxTests/Network/OperationsURLRoutingTests.swift +++ b/MindboxTests/Network/OperationsURLRoutingTests.swift @@ -29,13 +29,10 @@ struct OperationsURLRoutingTests { let syncWrapper = Self.makeEventWrapper(.syncEvent, bodyJSON: #"{"name":"X","payload":"{}"}"#) #expect(try builder.asURLRequest(route: EventRoute.syncEvent(syncWrapper)).url?.host == opsHost) - } - @Test("SDKLogsRoute uses operationsDomain when configured") - func sdkLogsRouteUsesOperationsDomain() throws { - let builder = URLRequestBuilder(domain: domain, operationsDomain: opsHost) - let url = try builder.asURLRequest(route: SDKLogsRoute()).url - #expect(url?.host == opsHost) + // SDK logs flow through the same `EventRoute.asyncEvent` (see `MBEventRepository.makeRoute`). + let logsWrapper = Self.makeEventWrapper(.sdkLogs) + #expect(try builder.asURLRequest(route: EventRoute.asyncEvent(logsWrapper)).url?.host == opsHost) } @Test("Config and geo routes always use domain") @@ -51,10 +48,11 @@ struct OperationsURLRoutingTests { func noOperationsDomainFallsBackToDomain() throws { let builder = URLRequestBuilder(domain: domain) let wrapper = Self.makeEventWrapper(.installed) + let logsWrapper = Self.makeEventWrapper(.sdkLogs) #expect(try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url?.host == domain) #expect(try builder.asURLRequest(route: EventRoute.trackVisit(wrapper)).url?.host == domain) - #expect(try builder.asURLRequest(route: SDKLogsRoute()).url?.host == domain) + #expect(try builder.asURLRequest(route: EventRoute.asyncEvent(logsWrapper)).url?.host == domain) #expect(try builder.asURLRequest(route: FetchInAppGeoRoute()).url?.host == domain) } From 372319540cbab15270b1c403ff37342915023b9b Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:03:44 +0300 Subject: [PATCH 10/24] MOBILE-130: Accept domain inputs with optional scheme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MBConfiguration.domain` and `MBConfiguration.operationsDomain` now accept `host`, `https://host`, and `http://host` (with or without trailing slash). When a scheme is present in the input, requests are built with that scheme; otherwise `https://` is used as before. Mirrors the Android SDK's `SdkValidation.extractHost` / `toBaseUrl` pair, so both platforms accept the same inputs (e.g. a value pasted straight from the dashboard URL). Adds `Mindbox/Network/Helpers/HostNormalizer.swift`: - `extractHost` — strips scheme (case-insensitive), whitespace, and trailing slashes. - `toBaseURLString` — preserves an existing scheme or prepends `https://`. - `isValidHost` — runs the extracted host through `URLValidator`. Encapsulates the awkward `URL(string: "https://" + host)` + `URLValidator` pattern previously duplicated across three call sites. `URLRequestBuilder` builds the base URL via `HostNormalizer.toBaseURLString(...)` before adding `path` / `queryItems`, so the resolved scheme propagates to every route. Same value also flows through `OperationsDomainConfigPolicy` when validating the operations host coming from the JSON config. Tests: - `HostNormalizerTests` (Swift Testing) — extract / base-URL / validation across scheme, case, trailing slash, whitespace. - `MBConfigurationTests` — accepts `domain` / `operationsDomain` with `https://`, `http://`, trailing slash. Replaces the previous "scheme throws" invariant. - `OperationsURLRoutingTests` — bare host → https default, `https://` / `http://` preserved end-to-end, trailing slash stripped before path append. Backwards compatible: integrators who pass a bare host get exactly the same URLs as before. --- Mindbox.xcodeproj/project.pbxproj | 8 ++ .../OperationsDomainConfigPolicy.swift | 3 +- Mindbox/MBConfiguration.swift | 4 +- Mindbox/Network/Helpers/HostNormalizer.swift | 48 +++++++++ .../Network/Helpers/URLRequestBuilder.swift | 5 +- .../Configuration/MBConfigurationTests.swift | 31 +++++- .../Network/HostNormalizerTests.swift | 102 ++++++++++++++++++ .../Network/OperationsURLRoutingTests.swift | 52 +++++++++ 8 files changed, 241 insertions(+), 12 deletions(-) create mode 100644 Mindbox/Network/Helpers/HostNormalizer.swift create mode 100644 MindboxTests/Network/HostNormalizerTests.swift diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index 00671529e..11bf43707 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ 31EB907325C402F900368FFB /* TestConfig3.plist in Resources */ = {isa = PBXBuildFile; fileRef = 31EB907125C402F900368FFB /* TestConfig3.plist */; }; 31EB907425C402F900368FFB /* TestConfig2.plist in Resources */ = {isa = PBXBuildFile; fileRef = 31EB907225C402F900368FFB /* TestConfig2.plist */; }; F3CD20262F600A800065392A /* MBConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CD20272F600A800065392A /* MBConfigurationTests.swift */; }; + F3CD202B2F600A800065392A /* HostNormalizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CD202C2F600A800065392A /* HostNormalizerTests.swift */; }; 31ED2DF225C4456600301FAD /* TestConfig_Invalid_2.plist in Resources */ = {isa = PBXBuildFile; fileRef = 31ED2DEF25C4456600301FAD /* TestConfig_Invalid_2.plist */; }; 31ED2DF325C4456600301FAD /* TestConfig_Invalid_1.plist in Resources */ = {isa = PBXBuildFile; fileRef = 31ED2DF025C4456600301FAD /* TestConfig_Invalid_1.plist */; }; 31ED2DF425C4456600301FAD /* TestConfig_Invalid_3.plist in Resources */ = {isa = PBXBuildFile; fileRef = 31ED2DF125C4456600301FAD /* TestConfig_Invalid_3.plist */; }; @@ -57,6 +58,7 @@ 3333C1B22681D42000B60D84 /* Payload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3333C1B12681D42000B60D84 /* Payload.swift */; }; 3333C1B42681D43C00B60D84 /* ImageFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3333C1B32681D43C00B60D84 /* ImageFormat.swift */; }; 3333C1DE2681E9F300B60D84 /* URLRequestBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3333C1DD2681E9F300B60D84 /* URLRequestBuilder.swift */; }; + F3CD20292F600A800065392A /* HostNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CD202A2F600A800065392A /* HostNormalizer.swift */; }; 3333C1E12681EA4D00B60D84 /* NotificationsPayloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3333C1E02681EA4C00B60D84 /* NotificationsPayloads.swift */; }; 3333D7BE265E56F2004279B0 /* OperationResponseType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3333D7BD265E56F2004279B0 /* OperationResponseType.swift */; }; 3337E6A3265FAB39006949EB /* BaseResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3337E6A2265FAB39006949EB /* BaseResponse.swift */; }; @@ -758,6 +760,7 @@ 31EB907125C402F900368FFB /* TestConfig3.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig3.plist; sourceTree = ""; }; 31EB907225C402F900368FFB /* TestConfig2.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig2.plist; sourceTree = ""; }; F3CD20272F600A800065392A /* MBConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MBConfigurationTests.swift; sourceTree = ""; }; + F3CD202C2F600A800065392A /* HostNormalizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostNormalizerTests.swift; sourceTree = ""; }; 31ED2DEF25C4456600301FAD /* TestConfig_Invalid_2.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig_Invalid_2.plist; sourceTree = ""; }; 31ED2DF025C4456600301FAD /* TestConfig_Invalid_1.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig_Invalid_1.plist; sourceTree = ""; }; 31ED2DF125C4456600301FAD /* TestConfig_Invalid_3.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig_Invalid_3.plist; sourceTree = ""; }; @@ -789,6 +792,7 @@ 3333C1B12681D42000B60D84 /* Payload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Payload.swift; sourceTree = ""; }; 3333C1B32681D43C00B60D84 /* ImageFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageFormat.swift; sourceTree = ""; }; 3333C1DD2681E9F300B60D84 /* URLRequestBuilder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLRequestBuilder.swift; sourceTree = ""; }; + F3CD202A2F600A800065392A /* HostNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostNormalizer.swift; sourceTree = ""; }; 3333C1E02681EA4C00B60D84 /* NotificationsPayloads.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationsPayloads.swift; sourceTree = ""; }; 3333D7BD265E56F2004279B0 /* OperationResponseType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationResponseType.swift; sourceTree = ""; }; 3337E6A2265FAB39006949EB /* BaseResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseResponse.swift; sourceTree = ""; }; @@ -2272,6 +2276,7 @@ children = ( 97FEDDEB5F71A67F1C4C675F /* MBNetworkFetcherResponseHandlingTests.swift */, F3BA5E000130A000C0000006 /* OperationsURLRoutingTests.swift */, + F3CD202C2F600A800065392A /* HostNormalizerTests.swift */, ); name = Network; path = Network; @@ -2390,6 +2395,7 @@ isa = PBXGroup; children = ( 3333C1DD2681E9F300B60D84 /* URLRequestBuilder.swift */, + F3CD202A2F600A800065392A /* HostNormalizer.swift */, 84EAEDFB25C8B18B00726063 /* DeviceModelHelper.swift */, ); path = Helpers; @@ -4425,6 +4431,7 @@ 9B24FAB528C751E400F10B5D /* InAppImagesStorage.swift in Sources */, F3A8B9A32A3A6E6900E9C055 /* SdkVersionModel.swift in Sources */, 3333C1DE2681E9F300B60D84 /* URLRequestBuilder.swift in Sources */, + F3CD20292F600A800065392A /* HostNormalizer.swift in Sources */, F315503F2BBB24E20072A071 /* TTLValidationService.swift in Sources */, F3BA5E000130A000C0000007 /* OperationsDomainConfigPolicy.swift in Sources */, 334F3AF5264C199900A6AC00 /* CodableDictionary.swift in Sources */, @@ -4766,6 +4773,7 @@ 302E35788CBDA959283569F4 /* MotionServiceBehaviorTests.swift in Sources */, 1E3BD63AB3F1521C253CB818 /* MBNetworkFetcherResponseHandlingTests.swift in Sources */, F3BA5E000130A000C0000005 /* OperationsURLRoutingTests.swift in Sources */, + F3CD202B2F600A800065392A /* HostNormalizerTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift index bec7e4616..33c3a8563 100644 --- a/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift +++ b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift @@ -26,8 +26,7 @@ enum OperationsDomainConfigPolicy { return currentlyStored == nil ? .keep : .clear } - guard let url = URL(string: "https://" + value), - URLValidator(url: url).evaluate() else { + guard HostNormalizer.isValidHost(value) else { return .keep } diff --git a/Mindbox/MBConfiguration.swift b/Mindbox/MBConfiguration.swift index 1091c6534..41322d1cf 100644 --- a/Mindbox/MBConfiguration.swift +++ b/Mindbox/MBConfiguration.swift @@ -51,7 +51,7 @@ public struct MBConfiguration: Codable { self.endpoint = endpoint self.domain = domain - guard let url = URL(string: "https://" + domain), URLValidator(url: url).evaluate() else { + guard HostNormalizer.isValidHost(domain) else { let error = MindboxError(.init(errorKey: .invalidConfiguration, reason: "Invalid domain. Domain is unreachable. [Domain]: \(domain)")) Logger.error(error.asLoggerError()) throw error @@ -64,7 +64,7 @@ public struct MBConfiguration: Codable { } if let operationsDomain = operationsDomain, !operationsDomain.isEmpty { - guard let url = URL(string: "https://" + operationsDomain), URLValidator(url: url).evaluate() else { + guard HostNormalizer.isValidHost(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 new file mode 100644 index 000000000..9eaf82b1d --- /dev/null +++ b/Mindbox/Network/Helpers/HostNormalizer.swift @@ -0,0 +1,48 @@ +// +// 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 { + + /// Strips scheme (case-insensitive), whitespace, and trailing slashes. + static func extractHost(_ raw: String) -> String { + var value = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if value.lowercased().hasPrefix("https://") { + value = String(value.dropFirst("https://".count)) + } else if value.lowercased().hasPrefix("http://") { + value = String(value.dropFirst("http://".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() } + let lower = value.lowercased() + if lower.hasPrefix("http://") || lower.hasPrefix("https://") { + return value + } + return "https://" + value + } + + /// Validates the extracted host. The `https://` prefix is required by + /// `URLValidator`'s full-URL regex — to be folded into URLValidator on rewrite. + static func isValidHost(_ raw: String) -> Bool { + let host = extractHost(raw) + guard !host.isEmpty, + let url = URL(string: "https://" + host) else { return false } + return URLValidator(url: url).evaluate() + } +} diff --git a/Mindbox/Network/Helpers/URLRequestBuilder.swift b/Mindbox/Network/Helpers/URLRequestBuilder.swift index 86cd71810..a73aac867 100644 --- a/Mindbox/Network/Helpers/URLRequestBuilder.swift +++ b/Mindbox/Network/Helpers/URLRequestBuilder.swift @@ -38,9 +38,8 @@ struct URLRequestBuilder { } private func makeURLComponents(for route: Route) -> URLComponents { - var components = URLComponents() - components.scheme = "https" - components.host = resolvedHost(for: route) + let baseURL = HostNormalizer.toBaseURLString(resolvedHost(for: route)) + var components = URLComponents(string: baseURL) ?? URLComponents() components.path = route.path components.queryItems = makeQueryItems(for: route.queryParameters) diff --git a/MindboxTests/Configuration/MBConfigurationTests.swift b/MindboxTests/Configuration/MBConfigurationTests.swift index bde35779a..76320010e 100644 --- a/MindboxTests/Configuration/MBConfigurationTests.swift +++ b/MindboxTests/Configuration/MBConfigurationTests.swift @@ -39,11 +39,32 @@ struct MBConfigurationTests { } } - @Test("Domain with embedded scheme throws") - func domainWithSchemeThrows() { - #expect(throws: MindboxError.self) { - _ = try MBConfiguration(endpoint: endpoint, domain: "https://api.mindbox.ru") - } + @Test("Domain accepts https:// prefix") + func domainAcceptsHttpsPrefix() throws { + let config = try MBConfiguration(endpoint: endpoint, domain: "https://api.mindbox.ru") + #expect(config.domain == "https://api.mindbox.ru") + } + + @Test("Domain accepts http:// prefix") + func domainAcceptsHttpPrefix() throws { + let config = try MBConfiguration(endpoint: endpoint, domain: "http://proxy.example.com") + #expect(config.domain == "http://proxy.example.com") + } + + @Test("Domain accepts trailing slash") + func domainAcceptsTrailingSlash() throws { + let config = try MBConfiguration(endpoint: endpoint, domain: "api.mindbox.ru/") + #expect(config.domain == "api.mindbox.ru/") + } + + @Test("operationsDomain accepts https:// prefix") + func operationsDomainAcceptsHttpsPrefix() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "https://anonymizer.client.ru" + ) + #expect(config.operationsDomain == "https://anonymizer.client.ru") } // MARK: - Init: endpoint validation diff --git a/MindboxTests/Network/HostNormalizerTests.swift b/MindboxTests/Network/HostNormalizerTests.swift new file mode 100644 index 000000000..341656a8d --- /dev/null +++ b/MindboxTests/Network/HostNormalizerTests.swift @@ -0,0 +1,102 @@ +// +// HostNormalizerTests.swift +// MindboxTests +// +// Created by Sergei Semko on 4/27/26. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +import Testing +@testable import Mindbox + +@Suite("HostNormalizer") +struct HostNormalizerTests { + + // MARK: - extractHost + + @Test("Bare host is returned unchanged") + func bareHost() { + #expect(HostNormalizer.extractHost("api.mindbox.ru") == "api.mindbox.ru") + } + + @Test("https:// prefix is stripped") + func stripsHttps() { + #expect(HostNormalizer.extractHost("https://api.mindbox.ru") == "api.mindbox.ru") + } + + @Test("http:// prefix is stripped") + func stripsHttp() { + #expect(HostNormalizer.extractHost("http://api.mindbox.ru") == "api.mindbox.ru") + } + + @Test("Scheme stripping is case-insensitive") + func schemeCaseInsensitive() { + #expect(HostNormalizer.extractHost("HTTPS://api.mindbox.ru") == "api.mindbox.ru") + #expect(HostNormalizer.extractHost("HtTp://api.mindbox.ru") == "api.mindbox.ru") + } + + @Test("Trailing slashes are removed") + func stripsTrailingSlashes() { + #expect(HostNormalizer.extractHost("api.mindbox.ru/") == "api.mindbox.ru") + #expect(HostNormalizer.extractHost("api.mindbox.ru///") == "api.mindbox.ru") + } + + @Test("Whitespace is trimmed") + func trimsWhitespace() { + #expect(HostNormalizer.extractHost(" api.mindbox.ru ") == "api.mindbox.ru") + } + + @Test("Combined scheme + trailing slash + whitespace") + func combinedNormalization() { + #expect(HostNormalizer.extractHost(" https://api.mindbox.ru/ ") == "api.mindbox.ru") + } + + // MARK: - toBaseURLString + + @Test("Bare host gets https:// prepended") + func bareHostGetsHttps() { + #expect(HostNormalizer.toBaseURLString("api.mindbox.ru") == "https://api.mindbox.ru") + } + + @Test("https:// is preserved") + func httpsPreserved() { + #expect(HostNormalizer.toBaseURLString("https://api.mindbox.ru") == "https://api.mindbox.ru") + } + + @Test("http:// is preserved") + func httpPreserved() { + #expect(HostNormalizer.toBaseURLString("http://proxy.example.com") == "http://proxy.example.com") + } + + @Test("Trailing slash is stripped from base URL") + func baseURLStripsTrailingSlash() { + #expect(HostNormalizer.toBaseURLString("https://api.mindbox.ru/") == "https://api.mindbox.ru") + #expect(HostNormalizer.toBaseURLString("api.mindbox.ru/") == "https://api.mindbox.ru") + } + + // MARK: - isValidHost + + @Test("Valid bare host passes") + func validBareHostPasses() { + #expect(HostNormalizer.isValidHost("api.mindbox.ru")) + } + + @Test("Valid host with https:// prefix passes") + func validHostWithSchemePasses() { + #expect(HostNormalizer.isValidHost("https://api.mindbox.ru")) + #expect(HostNormalizer.isValidHost("http://proxy.example.com")) + } + + @Test("Empty input fails") + func emptyHostFails() { + #expect(!HostNormalizer.isValidHost("")) + #expect(!HostNormalizer.isValidHost(" ")) + #expect(!HostNormalizer.isValidHost("https://")) + } + + @Test("Whitespace inside host fails") + func whitespaceInsideHostFails() { + #expect(!HostNormalizer.isValidHost("api mindbox ru")) + } +} diff --git a/MindboxTests/Network/OperationsURLRoutingTests.swift b/MindboxTests/Network/OperationsURLRoutingTests.swift index 368fa9d89..3ce1b119c 100644 --- a/MindboxTests/Network/OperationsURLRoutingTests.swift +++ b/MindboxTests/Network/OperationsURLRoutingTests.swift @@ -67,6 +67,58 @@ struct OperationsURLRoutingTests { #expect(url?.query?.contains("endpointId=test-endpoint") == true) } + // MARK: - Scheme handling (host-with-scheme passthrough) + + @Test("Bare host gets default https:// scheme") + func bareHostUsesHttps() throws { + let builder = URLRequestBuilder(domain: "api.mindbox.ru") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.scheme == "https") + #expect(url?.host == "api.mindbox.ru") + } + + @Test("Explicit https:// in domain is preserved") + func explicitHttpsPreserved() throws { + let builder = URLRequestBuilder(domain: "https://api.mindbox.ru") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.scheme == "https") + #expect(url?.host == "api.mindbox.ru") + } + + @Test("Explicit http:// in domain is preserved (proxy/staging case)") + func explicitHttpPreserved() throws { + let builder = URLRequestBuilder(domain: "http://proxy.example.com") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.scheme == "http") + #expect(url?.host == "proxy.example.com") + } + + @Test("Explicit https:// in operationsDomain is preserved") + func explicitHttpsInOperationsDomainPreserved() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: "https://anonymizer.client.ru") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.scheme == "https") + #expect(url?.host == "anonymizer.client.ru") + } + + @Test("Trailing slash in host input is stripped before path append") + func trailingSlashStripped() throws { + let builder = URLRequestBuilder(domain: "api.mindbox.ru/") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.path == "/v3/operations/async") + #expect(url?.host == "api.mindbox.ru") + } + // MARK: - Rollback signals from JSON config // // Happy-path and key/type errors live in `SettingsConfigParsingTests` From d2a77e59ccc7f9cee2255c56136cd898a908bb65 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:24:56 +0300 Subject: [PATCH 11/24] MOBILE-130: Rewrite URLValidator as a structural host validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the old full-URL regex (hardcoded TLD list missing `.app`/`.dev`/`.io`, `&` HTML-escape bug, per-call regex compile via `try!`) with an RFC 1123-style host validator. New TLDs are accepted automatically by structure — analogous to Android's `PatternsCompat.DOMAIN_NAME`. API: `URLValidator.isValidHost(_ host: String) -> Bool` (static, no instance). Drops the previous `URLValidator(url:).evaluate()` shape; callers (`MBConfiguration`, `OperationsDomainConfigPolicy`) are updated. Implementation is plain Swift (no regex, no `try!`): - Each label: 1..63 chars, alnum + hyphen, hyphen not at edges. - Hosts joined by `.`; single-label (`localhost`) and IPv4 literals accepted (digits-only labels are valid alnum). - Total length capped at 253 (RFC 1035), constants explicit. - ASCII-only contract: punycode (`xn--…`) passes, Unicode literals don't (callers must convert IDN to ACE). Removes the thin `HostNormalizer.isValidHost` wrapper — it was a trivial composition of `extractHost` + validation that didn't belong in a normalizer. Call sites now spell out the chain explicitly: `URLValidator.isValidHost(HostNormalizer.extractHost(value))`. Tests: - New `URLValidatorTests` (Swift Testing) with 22 cases covering passing inputs (modern TLDs, localhost, IPv4, hyphenated and single-char labels, mixed case, punycode, max label/host length boundaries) and failing inputs (empty, whitespace, underscore, edge-hyphen, empty labels, embedded scheme, path/query, special characters, Unicode literals, length overflow). - Removed `testURLValidator` from `ValidatorsTestCase` — the full-URL contract no longer applies. - Removed redundant `isValidHost` tests from `HostNormalizerTests` (covered by `URLValidatorTests` now). --- Mindbox.xcodeproj/project.pbxproj | 4 + .../OperationsDomainConfigPolicy.swift | 2 +- Mindbox/MBConfiguration.swift | 4 +- Mindbox/Network/Helpers/HostNormalizer.swift | 9 - Mindbox/Validators/URLValidator.swift | 40 +++-- .../Network/HostNormalizerTests.swift | 25 --- .../Validators/URLValidatorTests.swift | 159 ++++++++++++++++++ .../Validators/ValidatorsTestCase.swift | 24 --- 8 files changed, 191 insertions(+), 76 deletions(-) create mode 100644 MindboxTests/Validators/URLValidatorTests.swift diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index 11bf43707..026c0f197 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -313,6 +313,7 @@ 84B625E425C988FA00AB6228 /* URLValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B625E325C988FA00AB6228 /* URLValidator.swift */; }; 84B625E925C989C100AB6228 /* UDIDValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B625E825C989C100AB6228 /* UDIDValidator.swift */; }; 84B625F025C98B1200AB6228 /* ValidatorsTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B625EF25C98B1200AB6228 /* ValidatorsTestCase.swift */; }; + F3CD202D2F600A800065392A /* URLValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CD202E2F600A800065392A /* URLValidatorTests.swift */; }; 84BAEF8225D54919002E8A26 /* BodyDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84BAEF8125D54919002E8A26 /* BodyDecoder.swift */; }; 84C65E5E25D4FBA3008996FA /* MobileApplicationInstalled.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C65E5D25D4FBA3008996FA /* MobileApplicationInstalled.swift */; }; 84C65E6425D4FBBB008996FA /* MobileApplicationInfoUpdated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C65E6325D4FBBB008996FA /* MobileApplicationInfoUpdated.swift */; }; @@ -1039,6 +1040,7 @@ 84B625E325C988FA00AB6228 /* URLValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLValidator.swift; sourceTree = ""; }; 84B625E825C989C100AB6228 /* UDIDValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UDIDValidator.swift; sourceTree = ""; }; 84B625EF25C98B1200AB6228 /* ValidatorsTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidatorsTestCase.swift; sourceTree = ""; }; + F3CD202E2F600A800065392A /* URLValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLValidatorTests.swift; sourceTree = ""; }; 84BAEF8125D54919002E8A26 /* BodyDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BodyDecoder.swift; sourceTree = ""; }; 84C65E5D25D4FBA3008996FA /* MobileApplicationInstalled.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileApplicationInstalled.swift; sourceTree = ""; }; 84C65E6325D4FBBB008996FA /* MobileApplicationInfoUpdated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileApplicationInfoUpdated.swift; sourceTree = ""; }; @@ -2451,6 +2453,7 @@ F35E0C4D2DF0535E00E8A768 /* InAppTrackingServiceTests.swift */, F3A961D52DE9C5220016D5D3 /* InAppPresentationValidatorTests.swift */, 84B625EF25C98B1200AB6228 /* ValidatorsTestCase.swift */, + F3CD202E2F600A800065392A /* URLValidatorTests.swift */, F3A8B9972A3A421C00E9C055 /* SDKVersionValidatorTests.swift */, F30629192BD27D7500EF6609 /* InappFrequencyTests.swift */, ); @@ -4676,6 +4679,7 @@ 9B9C9538292111A700BB29DA /* MockUUIDDebugService.swift in Sources */, 47A4FA782E73741700569870 /* LoggerDatabaseLoaderTests.swift in Sources */, 84B625F025C98B1200AB6228 /* ValidatorsTestCase.swift in Sources */, + F3CD202D2F600A800065392A /* URLValidatorTests.swift in Sources */, 4741425E2E8A688300839AD8 /* DataBaseLoading_StubDatabaseLoaderContractTests.swift in Sources */, 847F580325C88BBF00147A9A /* HTTPMethod.swift in Sources */, F351F1C22CE5F23A0053423E /* InappMapperTests.swift in Sources */, diff --git a/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift index 33c3a8563..c9fbbcc60 100644 --- a/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift +++ b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift @@ -26,7 +26,7 @@ enum OperationsDomainConfigPolicy { return currentlyStored == nil ? .keep : .clear } - guard HostNormalizer.isValidHost(value) else { + guard URLValidator.isValidHost(HostNormalizer.extractHost(value)) else { return .keep } diff --git a/Mindbox/MBConfiguration.swift b/Mindbox/MBConfiguration.swift index 41322d1cf..6933238fe 100644 --- a/Mindbox/MBConfiguration.swift +++ b/Mindbox/MBConfiguration.swift @@ -51,7 +51,7 @@ public struct MBConfiguration: Codable { self.endpoint = endpoint self.domain = domain - guard HostNormalizer.isValidHost(domain) 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 @@ -64,7 +64,7 @@ public struct MBConfiguration: Codable { } if let operationsDomain = operationsDomain, !operationsDomain.isEmpty { - guard HostNormalizer.isValidHost(operationsDomain) else { + 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 diff --git a/Mindbox/Network/Helpers/HostNormalizer.swift b/Mindbox/Network/Helpers/HostNormalizer.swift index 9eaf82b1d..a047cbfe1 100644 --- a/Mindbox/Network/Helpers/HostNormalizer.swift +++ b/Mindbox/Network/Helpers/HostNormalizer.swift @@ -36,13 +36,4 @@ enum HostNormalizer { } return "https://" + value } - - /// Validates the extracted host. The `https://` prefix is required by - /// `URLValidator`'s full-URL regex — to be folded into URLValidator on rewrite. - static func isValidHost(_ raw: String) -> Bool { - let host = extractHost(raw) - guard !host.isEmpty, - let url = URL(string: "https://" + host) else { return false } - return URLValidator(url: url).evaluate() - } } diff --git a/Mindbox/Validators/URLValidator.swift b/Mindbox/Validators/URLValidator.swift index cab0b22c9..8f2e3f9c9 100644 --- a/Mindbox/Validators/URLValidator.swift +++ b/Mindbox/Validators/URLValidator.swift @@ -8,26 +8,36 @@ import Foundation -// FIXME: Rewrite this struct in the future +/// Validates a bare hostname (e.g. `api.mindbox.ru`, `localhost`, `192.168.1.1`) +/// using RFC 1123 label structure. No TLD allow-list — new TLDs (`.app`, `.dev`, …) +/// are accepted automatically. Analogous to Android's `PatternsCompat.DOMAIN_NAME`. +enum URLValidator { -struct URLValidator { + /// RFC 1035: full hostname max 253 chars. + private static let maxHostLength = 253 - let url: URL + /// RFC 1035: each label 1..63 chars. + private static let maxLabelLength = 63 - // swiftlint:disable:next line_length - let urlPattern = "^(http|https|ftp)\\://([a-zA-Z0-9\\.\\-]+(\\:[a-zA-Z0-9\\.&%\\$\\-]+)*@)*((25[0-5]|2[0-4][0-9]|[0-1]{1}[0-9]{2}|[1-9]{1}[0-9]{1}|[1-9])\\.(25[0-5]|2[0-4][0-9]|[0-1]{1}[0-9]{2}|[1-9]{1}[0-9]{1}|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1]{1}[0-9]{2}|[1-9]{1}[0-9]{1}|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1]{1}[0-9]{2}|[1-9]{1}[0-9]{1}|[0-9])|localhost|([a-zA-Z0-9\\-]+\\.)*[a-zA-Z0-9\\-]+\\.(com|cloud|edu|gov|int|mil|net|org|biz|arpa|info|name|pro|aero|coop|museum|tech|[a-zA-Z]{2}))(\\:[0-9]+)*(/($|[a-zA-Z0-9\\.\\,\\?\\'\\\\\\+&%\\$#\\=~_\\-]+))*$" + static func isValidHost(_ host: String) -> Bool { + guard !host.isEmpty, host.count <= maxHostLength else { return false } + return host + .split(separator: ".", omittingEmptySubsequences: false) + .allSatisfy(isValidLabel) + } - func evaluate() -> Bool { - return matches(string: url.absoluteString, pattern: urlPattern) + private static func isValidLabel(_ label: Substring) -> Bool { + guard (1...maxLabelLength).contains(label.count), + label.first != "-", + label.last != "-" + else { return false } + return label.unicodeScalars.allSatisfy(isAlnumOrHyphen) } - private func matches(string: String, pattern: String) -> Bool { - let regex = try! NSRegularExpression( // swiftlint:disable:this force_try - pattern: pattern, - options: [.caseInsensitive]) - return regex.firstMatch( - in: string, - options: [], - range: NSRange(location: 0, length: string.utf16.count)) != nil + private static func isAlnumOrHyphen(_ scalar: Unicode.Scalar) -> Bool { + ("a"..."z").contains(scalar) + || ("A"..."Z").contains(scalar) + || ("0"..."9").contains(scalar) + || scalar == "-" } } diff --git a/MindboxTests/Network/HostNormalizerTests.swift b/MindboxTests/Network/HostNormalizerTests.swift index 341656a8d..a427bf848 100644 --- a/MindboxTests/Network/HostNormalizerTests.swift +++ b/MindboxTests/Network/HostNormalizerTests.swift @@ -74,29 +74,4 @@ struct HostNormalizerTests { #expect(HostNormalizer.toBaseURLString("https://api.mindbox.ru/") == "https://api.mindbox.ru") #expect(HostNormalizer.toBaseURLString("api.mindbox.ru/") == "https://api.mindbox.ru") } - - // MARK: - isValidHost - - @Test("Valid bare host passes") - func validBareHostPasses() { - #expect(HostNormalizer.isValidHost("api.mindbox.ru")) - } - - @Test("Valid host with https:// prefix passes") - func validHostWithSchemePasses() { - #expect(HostNormalizer.isValidHost("https://api.mindbox.ru")) - #expect(HostNormalizer.isValidHost("http://proxy.example.com")) - } - - @Test("Empty input fails") - func emptyHostFails() { - #expect(!HostNormalizer.isValidHost("")) - #expect(!HostNormalizer.isValidHost(" ")) - #expect(!HostNormalizer.isValidHost("https://")) - } - - @Test("Whitespace inside host fails") - func whitespaceInsideHostFails() { - #expect(!HostNormalizer.isValidHost("api mindbox ru")) - } } diff --git a/MindboxTests/Validators/URLValidatorTests.swift b/MindboxTests/Validators/URLValidatorTests.swift new file mode 100644 index 000000000..e5d294d60 --- /dev/null +++ b/MindboxTests/Validators/URLValidatorTests.swift @@ -0,0 +1,159 @@ +// +// URLValidatorTests.swift +// MindboxTests +// +// Created by Sergei Semko on 4/27/26. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +import Testing +@testable import Mindbox + +@Suite("URLValidator.isValidHost") +struct URLValidatorTests { + + @Test("Common multi-label hosts pass") + func multiLabelHosts() { + #expect(URLValidator.isValidHost("api.mindbox.ru")) + #expect(URLValidator.isValidHost("anonymizer.client.ru")) + #expect(URLValidator.isValidHost("a.b.c.d.example.com")) + } + + @Test("Modern TLDs pass (no allow-list)") + func modernTLDs() { + #expect(URLValidator.isValidHost("example.app")) + #expect(URLValidator.isValidHost("example.dev")) + #expect(URLValidator.isValidHost("example.io")) + #expect(URLValidator.isValidHost("example.xyz")) + } + + @Test("Single-label host passes (localhost)") + func singleLabelHost() { + #expect(URLValidator.isValidHost("localhost")) + } + + @Test("IPv4 literal passes") + func ipv4Literal() { + #expect(URLValidator.isValidHost("192.168.1.1")) + #expect(URLValidator.isValidHost("10.0.0.1")) + } + + @Test("Hyphens inside labels pass") + func hyphensInside() { + #expect(URLValidator.isValidHost("host-with-dash.com")) + #expect(URLValidator.isValidHost("a-b-c.example.com")) + } + + @Test("Empty input fails") + func emptyFails() { + #expect(!URLValidator.isValidHost("")) + } + + @Test("Whitespace inside fails") + func whitespaceFails() { + #expect(!URLValidator.isValidHost("api mindbox ru")) + #expect(!URLValidator.isValidHost("\thost\t")) + } + + @Test("Underscore fails (RFC 1123)") + func underscoreFails() { + #expect(!URLValidator.isValidHost("host_name.com")) + } + + @Test("Leading/trailing hyphen fails") + func edgeHyphenFails() { + #expect(!URLValidator.isValidHost("-leading.com")) + #expect(!URLValidator.isValidHost("trailing-.com")) + } + + @Test("Empty labels fail") + func emptyLabelsFail() { + #expect(!URLValidator.isValidHost(".com")) + #expect(!URLValidator.isValidHost("api..mindbox.ru")) + #expect(!URLValidator.isValidHost("api.mindbox.ru.")) + } + + @Test("Embedded scheme fails (caller must strip first)") + func schemeFails() { + #expect(!URLValidator.isValidHost("https://api.mindbox.ru")) + } + + @Test("Path/query in host fails") + func pathFails() { + #expect(!URLValidator.isValidHost("api.mindbox.ru/path")) + #expect(!URLValidator.isValidHost("api.mindbox.ru?q=1")) + } + + @Test("Total length over 253 fails") + func tooLongFails() { + let label = String(repeating: "a", count: 60) // 60 chars per label, well-formed + let host = (1...5).map { _ in label }.joined(separator: ".") // 5*60 + 4 = 304 chars + #expect(!URLValidator.isValidHost(host)) + } + + @Test("63-char label is the max accepted") + func labelLengthBoundary() { + let valid = String(repeating: "a", count: 63) + ".com" + #expect(URLValidator.isValidHost(valid)) + let invalid = String(repeating: "a", count: 64) + ".com" + #expect(!URLValidator.isValidHost(invalid)) + } + + @Test("253-char total length is the max accepted") + func totalLengthBoundary() { + // 4 × 63-char labels + 3 dots = 255 → too long + let label63 = String(repeating: "a", count: 63) + let invalid = (1...4).map { _ in label63 }.joined(separator: ".") + #expect(invalid.count == 255) + #expect(!URLValidator.isValidHost(invalid)) + + // 3 × 63-char labels + 1 × 61-char label + 3 dots = 253 → valid + let label61 = String(repeating: "b", count: 61) + let valid = [label63, label63, label63, label61].joined(separator: ".") + #expect(valid.count == 253) + #expect(URLValidator.isValidHost(valid)) + } + + @Test("Single-character labels pass") + func singleCharLabels() { + #expect(URLValidator.isValidHost("a.b")) + #expect(URLValidator.isValidHost("x.y.z")) + } + + @Test("Mixed-case hosts pass") + func mixedCasePasses() { + #expect(URLValidator.isValidHost("API.Mindbox.RU")) + #expect(URLValidator.isValidHost("LocalHost")) + } + + @Test("Punycode IDN host passes (looks like alnum + hyphen)") + func punycodePasses() { + #expect(URLValidator.isValidHost("xn--80aswg.xn--p1ai")) + } + + @Test("Unicode literal IDN host fails (ASCII-only contract)") + func unicodeLiteralFails() { + #expect(!URLValidator.isValidHost("мойсайт.рф")) + } + + @Test("Special characters fail") + func specialCharsFail() { + #expect(!URLValidator.isValidHost("host!.com")) + #expect(!URLValidator.isValidHost("host*.com")) + #expect(!URLValidator.isValidHost("host%.com")) + #expect(!URLValidator.isValidHost("host:.com")) + #expect(!URLValidator.isValidHost("host@.com")) + } + + @Test("Single label with edge hyphen fails") + func singleLabelEdgeHyphen() { + #expect(!URLValidator.isValidHost("-host")) + #expect(!URLValidator.isValidHost("host-")) + } + + @Test("Single dot fails") + func singleDotFails() { + #expect(!URLValidator.isValidHost(".")) + } +} diff --git a/MindboxTests/Validators/ValidatorsTestCase.swift b/MindboxTests/Validators/ValidatorsTestCase.swift index 429b7306f..b6d2639a9 100644 --- a/MindboxTests/Validators/ValidatorsTestCase.swift +++ b/MindboxTests/Validators/ValidatorsTestCase.swift @@ -9,32 +9,8 @@ import XCTest @testable import Mindbox -// swiftlint:disable line_length - class ValidatorsTestCase: XCTestCase { - func testURLValidator() { - [ - "https://www.google.com/search?rlz=1C5CHFA_enRU848RU848&ei=GMYTYIKCK9SSwPAP8cWjiAM&q=umbrella+it&oq=umbrella+it&gs_lcp=CgZwc3ktYWIQAzIICAAQxwEQrwEyCAgAEMcBEK8BMgIIADICCAAyAggAMggIABDHARCvATICCAA6BQgAELEDOggIABCxAxCDAToICAAQxwEQowI6BggAEAoQAToOCAAQxwEQrwEQChABECo6BAgAEAo6BAgAEB46CgguELEDEEMQkwI6BAgAEEM6BwguELEDEEM6CggAEMcBEK8BEAo6BwgAELEDEAo6CwgAELEDEMcBEKMCOgUILhCxAzoOCAAQsQMQgwEQxwEQowI6CggAEAoQARBDECo6BwgAELEDEENQolhYx7IBYM61AWgJcAB4AIABcIgBqA2SAQQxNi40mAEAoAEBqgEHZ3dzLXdperABAMABAQ&sclient=psy-ab&ved=0ahUKEwiC7tnL28DuAhVUCRAIHfHiCDEQ4dUDCA0&uact=5", - - "http://www.google.com" - ] - .compactMap({ URL(string: $0) }) - .forEach { - XCTAssertTrue(URLValidator(url: $0).evaluate()) - } - - [ - "", - "https://www google com/", - "www.google.com" - ] - .compactMap { URL(string: $0) } - .forEach { - XCTAssertFalse(URLValidator(url: $0).evaluate()) - } - } - func testUDIDValidator() { XCTAssertFalse(UDIDValidator(udid: "00000000-0000-0000-0000-000000000000").evaluate()) XCTAssertFalse(UDIDValidator(udid: "00000000-0000-0000-0000").evaluate()) From 815c6834ce41cec848cf831ba9df62fb70024083 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:15:50 +0300 Subject: [PATCH 12/24] MOBILE-130: Tighten config-download fan-out and document softReset exclusion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract `applyDownloadedConfig(_:rawData:)` and `sendMonitoringLogsIfNeeded(_:)` so the .data branch reads as decode → assign → apply. Group the three `SessionTemporaryStorage` writes inside `setupSettingsFromConfig` into `applySessionStorageSettings(_:)` so the dispatcher reads as four steps. Move the rationale for excluding `operationsDomainFromConfig` from `softReset()` to the property's docstring, where it belongs. --- .../InAppConfigurationManager.swift | 43 ++++++++++++------- .../PersistenceStorage.swift | 6 +-- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift b/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift index 9d3ad9e51..d069456f7 100644 --- a/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift +++ b/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift @@ -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) @@ -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.") @@ -149,23 +159,26 @@ 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) - - persistOperationsDomain(from: settings.baseAddresses) - - saveConfigSessionToCache(settings.slidingExpiration?.config) } private func persistOperationsDomain(from baseAddresses: Settings.BaseAddresses?) { diff --git a/Mindbox/PersistenceStorage/PersistenceStorage.swift b/Mindbox/PersistenceStorage/PersistenceStorage.swift index 45e22e2bf..df7d067a8 100644 --- a/Mindbox/PersistenceStorage/PersistenceStorage.swift +++ b/Mindbox/PersistenceStorage/PersistenceStorage.swift @@ -55,6 +55,9 @@ protocol PersistenceStorage: AnyObject { /// Operations host cached from `settings.baseAddresses.operations` in the mobile /// JSON config. Persisted across launches; takes precedence over the init-time /// `MBConfiguration.operationsDomain` at request time. + /// Excluded from `softReset()` on purpose: clearing it on a migration reset would + /// route operations to `domain` until the next config load, breaking the + /// PD-safety guarantee. var operationsDomainFromConfig: String? { get set } /// The version code used to track the current state of migrations. @@ -102,9 +105,6 @@ extension PersistenceStorage { func softReset() { configDownloadDate = nil - // `operationsDomainFromConfig` is intentionally preserved: clearing it on a - // migration reset would route operations to `domain` until config reloads, - // breaking the PD-safety guarantee. shownDatesByInApp = nil handledlogRequestIds = nil lastInappStateChangeDate = nil From 230ffcbaebd43a6c64d499b33869b9108e74f251 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:24:59 +0300 Subject: [PATCH 13/24] MOBILE-130: Clean up public docstring for MBConfiguration.operationsDomain --- Mindbox/MBConfiguration.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Mindbox/MBConfiguration.swift b/Mindbox/MBConfiguration.swift index 6933238fe..b34e48ce8 100644 --- a/Mindbox/MBConfiguration.swift +++ b/Mindbox/MBConfiguration.swift @@ -32,9 +32,8 @@ public struct MBConfiguration: Codable { /// - Parameter subscribeCustomerIfCreated: Flag which determines subscription status of the user. Default value is `false`. /// - Parameter shouldCreateCustomer: Flag which determines create or not anonymous users. Usable only during first initialisation. Default value is `true`. /// - Parameter uuidDebugEnabled: Flag which determines if uuid debugging functionality is enabled. Default value is `true`. - /// - Parameter operationsDomain: Optional anonymizer host for `/v3/operations/*` and - /// `/v1.1/customer/mobile-track-visit`. Bare host without scheme. Overridden by the - /// value from the mobile JSON config when present. Default `nil` (use `domain`). + /// - Parameter operationsDomain: Optional host for sending operations. Overridden by + /// the value from the mobile JSON config when present. Default `nil` (use `domain`). /// /// - Throws:`MindboxError.internalError` for invalid initialization parameters public init( From 288d7912c3ab8715fa8ef9555163bf13b7c90647 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:29:18 +0300 Subject: [PATCH 14/24] MOBILE-130: Move operationsDomain next to domain in MBConfiguration init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new parameter now sits right after `domain` instead of at the end — they're conceptually paired (main host vs its anonymizer override). Source-compatible: callers that don't pass `operationsDomain` are unaffected; the few that do (tests, ios-app) already listed it adjacent to `domain` and only need a follow-up reorder of their own. --- Mindbox/MBConfiguration.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Mindbox/MBConfiguration.swift b/Mindbox/MBConfiguration.swift index b34e48ce8..47c75a430 100644 --- a/Mindbox/MBConfiguration.swift +++ b/Mindbox/MBConfiguration.swift @@ -27,25 +27,25 @@ 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`. /// - Parameter shouldCreateCustomer: Flag which determines create or not anonymous users. Usable only during first initialisation. Default value is `true`. /// - Parameter uuidDebugEnabled: Flag which determines if uuid debugging functionality is enabled. Default value is `true`. - /// - Parameter operationsDomain: Optional host for sending operations. Overridden by - /// the value from the mobile JSON config when present. Default `nil` (use `domain`). /// /// - Throws:`MindboxError.internalError` for invalid initialization parameters public init( endpoint: String, domain: String, + operationsDomain: String? = nil, previousInstallationId: String? = nil, previousDeviceUUID: String? = nil, subscribeCustomerIfCreated: Bool = false, shouldCreateCustomer: Bool = true, imageLoadingMaxTimeInSeconds: Double? = nil, - uuidDebugEnabled: Bool = true, - operationsDomain: String? = nil + uuidDebugEnabled: Bool = true ) throws { self.endpoint = endpoint self.domain = domain @@ -183,12 +183,12 @@ public struct MBConfiguration: Codable { try self.init( endpoint: endpoint, domain: domain, + operationsDomain: operationsDomain, previousInstallationId: previousInstallationId, previousDeviceUUID: previousDeviceUUID, subscribeCustomerIfCreated: subscribeCustomerIfCreated, shouldCreateCustomer: shouldCreateCustomer, - uuidDebugEnabled: uuidDebugEnabled, - operationsDomain: operationsDomain + uuidDebugEnabled: uuidDebugEnabled ) } } From 7c83fd3d6848c18d67ae7e7edbe6101352bb9c37 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:47:15 +0300 Subject: [PATCH 15/24] MOBILE-130: Store operationsDomain from JSON config in canonical scheme://host form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend may send `http://` for some anonymizer setups; previous logic stored the raw string verbatim, so trailing slashes / scheme-vs-host mismatches caused spurious re-saves. Normalize via HostNormalizer.toBaseURLString on save: scheme preserved (http/https), trailing slash stripped, missing scheme defaults to https. Idempotent — first config fetch after upgrade rewrites legacy values once, then `.keep` stably. Adapts existing Policy tests to the canonical form and adds coverage for the trailing-slash case from JSON config, http preservation, canonical-equality keep, and the legacy-value upgrade path. --- .../OperationsDomainConfigPolicy.swift | 5 +- .../Network/OperationsURLRoutingTests.swift | 48 ++++++++++++++++--- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift index c9fbbcc60..eae9a598c 100644 --- a/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift +++ b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift @@ -30,6 +30,9 @@ enum OperationsDomainConfigPolicy { return .keep } - return value == currentlyStored ? .keep : .save(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) } } diff --git a/MindboxTests/Network/OperationsURLRoutingTests.swift b/MindboxTests/Network/OperationsURLRoutingTests.swift index 3ce1b119c..2e0f7be4c 100644 --- a/MindboxTests/Network/OperationsURLRoutingTests.swift +++ b/MindboxTests/Network/OperationsURLRoutingTests.swift @@ -198,27 +198,27 @@ struct OperationsURLRoutingTests { @Test("Policy — saves a new valid value when storage is empty") func policySavesNewValueFromEmpty() { - #expect(OperationsDomainConfigPolicy.action(for: "x.ru", currentlyStored: nil) == .save("x.ru")) + #expect(OperationsDomainConfigPolicy.action(for: "x.ru", currentlyStored: nil) == .save("https://x.ru")) } @Test("Policy — saves when value changes") func policySavesOnChange() { - #expect(OperationsDomainConfigPolicy.action(for: "new.ru", currentlyStored: "old.ru") == .save("new.ru")) + #expect(OperationsDomainConfigPolicy.action(for: "new.ru", currentlyStored: "https://old.ru") == .save("https://new.ru")) } @Test("Policy — keeps when incoming value equals stored") func policyKeepsOnIdenticalValue() { - #expect(OperationsDomainConfigPolicy.action(for: "x.ru", currentlyStored: "x.ru") == .keep) + #expect(OperationsDomainConfigPolicy.action(for: "https://x.ru", currentlyStored: "https://x.ru") == .keep) } @Test("Policy — clears on null/missing config when something is stored") func policyClearsOnNullWhenStored() { - #expect(OperationsDomainConfigPolicy.action(for: nil, currentlyStored: "old.ru") == .clear) + #expect(OperationsDomainConfigPolicy.action(for: nil, currentlyStored: "https://old.ru") == .clear) } @Test("Policy — clears on empty string when something is stored") func policyClearsOnEmptyWhenStored() { - #expect(OperationsDomainConfigPolicy.action(for: "", currentlyStored: "old.ru") == .clear) + #expect(OperationsDomainConfigPolicy.action(for: "", currentlyStored: "https://old.ru") == .clear) } @Test("Policy — no-ops when nothing stored and nothing came") @@ -229,7 +229,43 @@ struct OperationsURLRoutingTests { @Test("Policy — preserves previous value when incoming host is format-broken") func policyKeepsOnInvalidFormat() { - #expect(OperationsDomainConfigPolicy.action(for: "host with spaces", currentlyStored: "good.ru") == .keep) + #expect(OperationsDomainConfigPolicy.action(for: "host with spaces", currentlyStored: "https://good.ru") == .keep) + } + + @Test("Policy — normalizes scheme + trailing slash to canonical form") + func policyNormalizesSchemeAndTrailingSlash() { + #expect( + OperationsDomainConfigPolicy.action( + for: "https://anonymizer-api-regular.client.ru/", + currentlyStored: nil + ) == .save("https://anonymizer-api-regular.client.ru") + ) + } + + @Test("Policy — preserves http scheme from config (does not force https)") + func policyPreservesHttpScheme() { + #expect( + OperationsDomainConfigPolicy.action(for: "http://x.ru/", currentlyStored: nil) + == .save("http://x.ru") + ) + } + + @Test("Policy — keeps when canonical form equals stored despite raw differences") + func policyKeepsWhenCanonicalFormMatches() { + #expect( + OperationsDomainConfigPolicy.action( + for: "https://x.ru/", + currentlyStored: "https://x.ru" + ) == .keep + ) + } + + @Test("Policy — upgrade path: legacy bare-host stored value re-saves once as canonical") + func policyUpgradesLegacyStoredValue() { + #expect( + OperationsDomainConfigPolicy.action(for: "x.ru", currentlyStored: "x.ru") + == .save("https://x.ru") + ) } // MARK: - Persistence lifecycle From 030ed8fc408c67209f24eb99beb4b2e902e5117e Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:27:08 +0300 Subject: [PATCH 16/24] MOBILE-130: Cover operationsDomain http/trailing-slash parity in init and routing tests Routing tests had three scheme/slash cases for `domain` (bare host, https preserved, http preserved, trailing-slash stripped) but only `https://` for `operationsDomain`. Same asymmetry in MBConfiguration init tests. Add the missing http/trailing-slash cases for `operationsDomain` in both, plus an end-to-end check that the canonical `scheme://host` form written by OperationsDomainConfigPolicy routes correctly through URLRequestBuilder. --- .../Configuration/MBConfigurationTests.swift | 20 ++++++++ .../Network/OperationsURLRoutingTests.swift | 49 ++++++++++++++++++- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/MindboxTests/Configuration/MBConfigurationTests.swift b/MindboxTests/Configuration/MBConfigurationTests.swift index 76320010e..dc3425ccf 100644 --- a/MindboxTests/Configuration/MBConfigurationTests.swift +++ b/MindboxTests/Configuration/MBConfigurationTests.swift @@ -67,6 +67,26 @@ struct MBConfigurationTests { #expect(config.operationsDomain == "https://anonymizer.client.ru") } + @Test("operationsDomain accepts http:// prefix") + func operationsDomainAcceptsHttpPrefix() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "http://anonymizer-staging.client.ru" + ) + #expect(config.operationsDomain == "http://anonymizer-staging.client.ru") + } + + @Test("operationsDomain accepts trailing slash") + func operationsDomainAcceptsTrailingSlash() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "anonymizer.client.ru/" + ) + #expect(config.operationsDomain == "anonymizer.client.ru/") + } + // MARK: - Init: endpoint validation @Test("Empty endpoint throws") diff --git a/MindboxTests/Network/OperationsURLRoutingTests.swift b/MindboxTests/Network/OperationsURLRoutingTests.swift index 2e0f7be4c..32d0fcf3d 100644 --- a/MindboxTests/Network/OperationsURLRoutingTests.swift +++ b/MindboxTests/Network/OperationsURLRoutingTests.swift @@ -99,6 +99,16 @@ struct OperationsURLRoutingTests { #expect(url?.host == "proxy.example.com") } + @Test("Bare operationsDomain gets default https:// scheme") + func bareOperationsDomainUsesHttps() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: "anonymizer.client.ru") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.scheme == "https") + #expect(url?.host == "anonymizer.client.ru") + } + @Test("Explicit https:// in operationsDomain is preserved") func explicitHttpsInOperationsDomainPreserved() throws { let builder = URLRequestBuilder(domain: domain, operationsDomain: "https://anonymizer.client.ru") @@ -109,8 +119,18 @@ struct OperationsURLRoutingTests { #expect(url?.host == "anonymizer.client.ru") } - @Test("Trailing slash in host input is stripped before path append") - func trailingSlashStripped() throws { + @Test("Explicit http:// in operationsDomain is preserved (proxy/staging case)") + func explicitHttpInOperationsDomainPreserved() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: "http://anonymizer-staging.client.ru") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.scheme == "http") + #expect(url?.host == "anonymizer-staging.client.ru") + } + + @Test("Trailing slash in domain is stripped before path append") + func trailingSlashInDomainStripped() throws { let builder = URLRequestBuilder(domain: "api.mindbox.ru/") let wrapper = Self.makeEventWrapper(.installed) @@ -119,6 +139,31 @@ struct OperationsURLRoutingTests { #expect(url?.host == "api.mindbox.ru") } + @Test("Trailing slash in operationsDomain is stripped before path append") + func trailingSlashInOperationsDomainStripped() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: "https://anonymizer.client.ru/") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.scheme == "https") + #expect(url?.host == "anonymizer.client.ru") + #expect(url?.path == "/v3/operations/async") + } + + @Test("Canonical form stored by policy routes correctly end-to-end") + func canonicalStoredFormRoutesCorrectly() throws { + // Mirrors what `OperationsDomainConfigPolicy` writes to PersistenceStorage: + // canonical `scheme://host` form. URLRequestBuilder must accept it as-is. + let canonical = "https://anonymizer-api-regular.client.ru" + let builder = URLRequestBuilder(domain: domain, operationsDomain: canonical) + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.scheme == "https") + #expect(url?.host == "anonymizer-api-regular.client.ru") + #expect(url?.path == "/v3/operations/async") + } + // MARK: - Rollback signals from JSON config // // Happy-path and key/type errors live in `SettingsConfigParsingTests` From 4abbd76f5fce8dc4e7a0649b05c1e272e6f5b976 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:56:30 +0300 Subject: [PATCH 17/24] MOBILE-130: Address review feedback on operationsDomain decode and reject path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MBConfiguration decoder: drop the `try?` on `decodeIfPresent` so the result is `String?` not `String??`. Type errors propagate, matching how `endpoint`, `domain`, and the bool fields are decoded. - OperationsDomainConfigPolicy: split `.keep` into `.keep` and `.rejected(String)`. Previously `.keep` collapsed both "already in canonical form" and "invalid format — fall back". After the canonicalization fix this caused spurious "Invalid domain" error logs whenever a legacy raw value (e.g. `x.ru`) matched a stored canonical form (`https://x.ru`). - InAppConfigurationManager: handle the new `.rejected` case explicitly, drop the brittle `current != raw` condition that motivated the bug. - Regression test covers the legacy-vs-canonical match no longer being mis-logged as rejected. --- .../InAppConfigurationManager.swift | 7 +++---- .../OperationsDomainConfigPolicy.swift | 10 +++++++--- Mindbox/MBConfiguration.swift | 2 +- .../Network/OperationsURLRoutingTests.swift | 19 ++++++++++++++++--- 4 files changed, 27 insertions(+), 11 deletions(-) diff --git a/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift b/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift index d069456f7..09f17ebe6 100644 --- a/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift +++ b/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift @@ -187,16 +187,15 @@ class InAppConfigurationManager: InAppConfigurationManagerProtocol { switch OperationsDomainConfigPolicy.action(for: raw, currentlyStored: current) { case .keep: - if let raw = raw, !raw.isEmpty, current != raw { - // `.keep` on a non-empty value means it was rejected by URLValidator. - Logger.common(message: "[OperationsDomain] Invalid domain from config — ignored, previous value kept. [Value]: \(raw)", level: .error, category: .inAppMessages) - } + 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) } } diff --git a/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift index eae9a598c..60cf0cd53 100644 --- a/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift +++ b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift @@ -16,9 +16,13 @@ enum OperationsDomainConfigPolicy { case save(String) /// Config explicitly cleared the value (null / missing / empty). case clear - /// No-op: equal to stored, both empty, or incoming value is format-broken - /// (one bad push must not destroy a working config). + /// 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 { @@ -27,7 +31,7 @@ enum OperationsDomainConfigPolicy { } guard URLValidator.isValidHost(HostNormalizer.extractHost(value)) else { - return .keep + return .rejected(value) } // Store canonical `scheme://host` so backend's choice of `http`/`https` diff --git a/Mindbox/MBConfiguration.swift b/Mindbox/MBConfiguration.swift index 47c75a430..ed73d63dd 100644 --- a/Mindbox/MBConfiguration.swift +++ b/Mindbox/MBConfiguration.swift @@ -164,7 +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) + 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 { diff --git a/MindboxTests/Network/OperationsURLRoutingTests.swift b/MindboxTests/Network/OperationsURLRoutingTests.swift index 32d0fcf3d..07c5726a3 100644 --- a/MindboxTests/Network/OperationsURLRoutingTests.swift +++ b/MindboxTests/Network/OperationsURLRoutingTests.swift @@ -272,9 +272,22 @@ struct OperationsURLRoutingTests { #expect(OperationsDomainConfigPolicy.action(for: "", currentlyStored: nil) == .keep) } - @Test("Policy — preserves previous value when incoming host is format-broken") - func policyKeepsOnInvalidFormat() { - #expect(OperationsDomainConfigPolicy.action(for: "host with spaces", currentlyStored: "https://good.ru") == .keep) + @Test("Policy — rejects format-broken incoming value (previous kept intact)") + func policyRejectsInvalidFormat() { + #expect( + OperationsDomainConfigPolicy.action(for: "host with spaces", currentlyStored: "https://good.ru") + == .rejected("host with spaces") + ) + } + + @Test("Policy — does NOT spuriously reject when canonical form matches stored (legacy raw)") + func policyDoesNotRejectOnLegacyRawMatch() { + // Pre-fix bug: `.keep` was logged as "rejected" whenever raw != stored, + // even when raw was valid and just normalized to the stored form. + #expect( + OperationsDomainConfigPolicy.action(for: "x.ru", currentlyStored: "https://x.ru") + == .keep + ) } @Test("Policy — normalizes scheme + trailing slash to canonical form") From ee494c10f7c349f5bff2c81a6e977e4413df8177 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:05:54 +0300 Subject: [PATCH 18/24] MOBILE-130: Address review feedback on validator strictness and URL build safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - URLValidator: enforce IPv4 octet ranges (0..255) for four-pure-digit-label inputs, matching Android's PatternsCompat.DOMAIN_NAME. `999.999.999.999` and `256.0.0.0` are now rejected; structural hostname rules unchanged. - URLRequestBuilder: stop silently falling back to an empty URLComponents when `URLComponents(string:)` fails — that produced relative URLs that passed the existing `components.url != nil` guard and hit the network with a bogus target. Throw `URLError.badURL` at the source. - Tests cover IPv4 overflow rejection, hostname pass-through for non-IPv4 numeric counts (3 / 5 labels), and the new fail-fast path. --- .../OperationsDomainConfigPolicy.swift | 1 + .../Network/Helpers/URLRequestBuilder.swift | 14 ++++++++--- Mindbox/Validators/URLValidator.swift | 21 +++++++++++++---- .../Network/OperationsURLRoutingTests.swift | 18 ++++++++++++--- .../Validators/URLValidatorTests.swift | 23 ++++++++++++++++++- 5 files changed, 66 insertions(+), 11 deletions(-) diff --git a/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift index 60cf0cd53..f1b456c5f 100644 --- a/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift +++ b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift @@ -15,6 +15,7 @@ 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. diff --git a/Mindbox/Network/Helpers/URLRequestBuilder.swift b/Mindbox/Network/Helpers/URLRequestBuilder.swift index a73aac867..60ba12625 100644 --- a/Mindbox/Network/Helpers/URLRequestBuilder.swift +++ b/Mindbox/Network/Helpers/URLRequestBuilder.swift @@ -20,7 +20,7 @@ struct URLRequestBuilder { } func asURLRequest(route: Route) throws -> URLRequest { - let components = makeURLComponents(for: route) + let components = try makeURLComponents(for: route) guard let url = components.url else { Logger.common(message: "Bad url. [URL]: \(String(describing: components.url))", level: .error, category: .network) @@ -37,9 +37,17 @@ struct URLRequestBuilder { return urlRequest } - private func makeURLComponents(for route: Route) -> URLComponents { + private func makeURLComponents(for route: Route) throws -> URLComponents { let baseURL = HostNormalizer.toBaseURLString(resolvedHost(for: route)) - var components = URLComponents(string: baseURL) ?? URLComponents() + + // Fail fast: if the base URL is unparseable, we used to fall back to an + // empty `URLComponents()` — `components.url` then returned a relative URL + // (just the path), which silently sent the request to a bogus target. + guard var components = URLComponents(string: baseURL) else { + Logger.common(message: "Failed to build base URL components. [Base]: \(baseURL)", level: .error, category: .network) + throw URLError(.badURL) + } + components.path = route.path components.queryItems = makeQueryItems(for: route.queryParameters) diff --git a/Mindbox/Validators/URLValidator.swift b/Mindbox/Validators/URLValidator.swift index 8f2e3f9c9..eb973f5c1 100644 --- a/Mindbox/Validators/URLValidator.swift +++ b/Mindbox/Validators/URLValidator.swift @@ -10,7 +10,8 @@ import Foundation /// Validates a bare hostname (e.g. `api.mindbox.ru`, `localhost`, `192.168.1.1`) /// using RFC 1123 label structure. No TLD allow-list — new TLDs (`.app`, `.dev`, …) -/// are accepted automatically. Analogous to Android's `PatternsCompat.DOMAIN_NAME`. +/// are accepted automatically. Analogous to Android's `PatternsCompat.DOMAIN_NAME`, +/// including its IPv4 octet-range enforcement. enum URLValidator { /// RFC 1035: full hostname max 253 chars. @@ -21,9 +22,16 @@ enum URLValidator { static func isValidHost(_ host: String) -> Bool { guard !host.isEmpty, host.count <= maxHostLength else { return false } - return host - .split(separator: ".", omittingEmptySubsequences: false) - .allSatisfy(isValidLabel) + + let labels = host.split(separator: ".", omittingEmptySubsequences: false) + + // Four pure-digit labels = IPv4 literal — enforce octet ranges so + // `999.999.999.999` is rejected (matches Android's PatternsCompat). + if labels.count == 4, labels.allSatisfy({ $0.allSatisfy(\.isASCII) && $0.allSatisfy(\.isNumber) }) { + return labels.allSatisfy(isValidIPv4Octet) + } + + return labels.allSatisfy(isValidLabel) } private static func isValidLabel(_ label: Substring) -> Bool { @@ -34,6 +42,11 @@ enum URLValidator { return label.unicodeScalars.allSatisfy(isAlnumOrHyphen) } + private static func isValidIPv4Octet(_ label: Substring) -> Bool { + guard (1...3).contains(label.count), let value = Int(label) else { return false } + return (0...255).contains(value) + } + private static func isAlnumOrHyphen(_ scalar: Unicode.Scalar) -> Bool { ("a"..."z").contains(scalar) || ("A"..."Z").contains(scalar) diff --git a/MindboxTests/Network/OperationsURLRoutingTests.swift b/MindboxTests/Network/OperationsURLRoutingTests.swift index 07c5726a3..2407c5428 100644 --- a/MindboxTests/Network/OperationsURLRoutingTests.swift +++ b/MindboxTests/Network/OperationsURLRoutingTests.swift @@ -164,6 +164,18 @@ struct OperationsURLRoutingTests { #expect(url?.path == "/v3/operations/async") } + @Test("Fails fast when base URL is unparseable (no silent relative-URL request)") + func failsFastOnUnparseableBaseURL() { + // Embedded space defeats both `URLComponents(string:)` parsing and + // makes the prior fallback build a bogus relative URL silently. + let builder = URLRequestBuilder(domain: "bad host with spaces") + let wrapper = Self.makeEventWrapper(.installed) + + #expect(throws: URLError.self) { + _ = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)) + } + } + // MARK: - Rollback signals from JSON config // // Happy-path and key/type errors live in `SettingsConfigParsingTests` @@ -256,12 +268,12 @@ struct OperationsURLRoutingTests { #expect(OperationsDomainConfigPolicy.action(for: "https://x.ru", currentlyStored: "https://x.ru") == .keep) } - @Test("Policy — clears on null/missing config when something is stored") + @Test("Policy — clears on null/missing config when something is stored (rollback)") func policyClearsOnNullWhenStored() { #expect(OperationsDomainConfigPolicy.action(for: nil, currentlyStored: "https://old.ru") == .clear) } - @Test("Policy — clears on empty string when something is stored") + @Test("Policy — clears on empty string when something is stored (rollback)") func policyClearsOnEmptyWhenStored() { #expect(OperationsDomainConfigPolicy.action(for: "", currentlyStored: "https://old.ru") == .clear) } @@ -282,7 +294,7 @@ struct OperationsURLRoutingTests { @Test("Policy — does NOT spuriously reject when canonical form matches stored (legacy raw)") func policyDoesNotRejectOnLegacyRawMatch() { - // Pre-fix bug: `.keep` was logged as "rejected" whenever raw != stored, + // Regression: `.keep` was previously logged as "rejected" whenever raw != stored, // even when raw was valid and just normalized to the stored form. #expect( OperationsDomainConfigPolicy.action(for: "x.ru", currentlyStored: "https://x.ru") diff --git a/MindboxTests/Validators/URLValidatorTests.swift b/MindboxTests/Validators/URLValidatorTests.swift index e5d294d60..3c6eca02f 100644 --- a/MindboxTests/Validators/URLValidatorTests.swift +++ b/MindboxTests/Validators/URLValidatorTests.swift @@ -33,10 +33,31 @@ struct URLValidatorTests { #expect(URLValidator.isValidHost("localhost")) } - @Test("IPv4 literal passes") + @Test("Valid IPv4 literals pass") func ipv4Literal() { #expect(URLValidator.isValidHost("192.168.1.1")) #expect(URLValidator.isValidHost("10.0.0.1")) + #expect(URLValidator.isValidHost("0.0.0.0")) + #expect(URLValidator.isValidHost("255.255.255.255")) + } + + @Test("IPv4 octet > 255 fails (parity with Android PatternsCompat)") + func ipv4OctetOverflowFails() { + #expect(!URLValidator.isValidHost("999.999.999.999")) + #expect(!URLValidator.isValidHost("256.0.0.0")) + #expect(!URLValidator.isValidHost("192.168.1.256")) + } + + @Test("Three numeric labels are NOT treated as IPv4 — fall through to hostname rules") + func threeNumericLabelsAreHostname() { + // 1.2.3 is not IPv4 (3 labels, not 4) and remains structurally a valid hostname. + #expect(URLValidator.isValidHost("1.2.3")) + } + + @Test("Five numeric labels are NOT treated as IPv4 — fall through to hostname rules") + func fiveNumericLabelsAreHostname() { + // 5 labels of digits are structurally a valid hostname even though not IPv4. + #expect(URLValidator.isValidHost("1.2.3.4.5")) } @Test("Hyphens inside labels pass") From 573d0cfb988c86f2a8b0900e0320997d52559dbe Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:20:33 +0300 Subject: [PATCH 19/24] MOBILE-130: Drop .prettyPrinted from trackVisit body serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `JSONSerialization.data(withJSONObject:options:)` was using `.prettyPrinted` for the trackVisit request body — wasted whitespace and indentation on every mobile-track-visit call. Server parses both forms identically; the change just trims a few dozen bytes per request and matches the codebase convention for network-path serialization. Default `options` value (`[]`) is fine, so the parameter is dropped entirely. --- Mindbox/NetworkRepository/Event/EventRoute.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mindbox/NetworkRepository/Event/EventRoute.swift b/Mindbox/NetworkRepository/Event/EventRoute.swift index 9ff8d4c90..17e6c0995 100644 --- a/Mindbox/NetworkRepository/Event/EventRoute.swift +++ b/Mindbox/NetworkRepository/Event/EventRoute.swift @@ -89,7 +89,7 @@ enum EventRoute: Route { json["endpointId"] = wrapper.endpoint - return try? JSONSerialization.data(withJSONObject: json, options: .prettyPrinted) + return try? JSONSerialization.data(withJSONObject: json) } } From bafc9f4527b6bafee21fab554103a15123a6dead Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:23:39 +0300 Subject: [PATCH 20/24] MOBILE-130: Use case-insensitive anchored range matching in HostNormalizer Replace `value.lowercased().hasPrefix(...)` with `value.range(of:options:[.caseInsensitive, .anchored])` so the scheme check no longer allocates a lowercased copy of the input on every call. Identical behaviour, cleaner intent (prefix-with-options vs. transform-then-check), and the prefix literals are pulled out as `static let` so `dropFirst(...)` no longer carries hard-coded length math. Adds a tiny `hasSchemePrefix` helper for `toBaseURLString` to avoid duplicating both range checks inline. --- Mindbox/Network/Helpers/HostNormalizer.swift | 22 +++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/Mindbox/Network/Helpers/HostNormalizer.swift b/Mindbox/Network/Helpers/HostNormalizer.swift index a047cbfe1..1dc2320b7 100644 --- a/Mindbox/Network/Helpers/HostNormalizer.swift +++ b/Mindbox/Network/Helpers/HostNormalizer.swift @@ -12,13 +12,16 @@ import Foundation /// 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.lowercased().hasPrefix("https://") { - value = String(value.dropFirst("https://".count)) - } else if value.lowercased().hasPrefix("http://") { - value = String(value.dropFirst("http://".count)) + 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() @@ -30,10 +33,15 @@ enum HostNormalizer { static func toBaseURLString(_ raw: String) -> String { var value = raw.trimmingCharacters(in: .whitespacesAndNewlines) while value.hasSuffix("/") { value.removeLast() } - let lower = value.lowercased() - if lower.hasPrefix("http://") || lower.hasPrefix("https://") { + if hasSchemePrefix(value) { return value } - return "https://" + 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 } } From 14b73fcb0efc9a9fe9eb847b03649a05bf9a5f02 Mon Sep 17 00:00:00 2001 From: Akylbek Utekeshev Date: Wed, 29 Apr 2026 20:08:45 +0500 Subject: [PATCH 21/24] MOBILE-120: Limit errorDetails to 1000 characters in InappShowFailureManager (#700) * MOBILE-120: Limit errorDetails to 1000 characters in InappShowFailureManager * MOBILE-120: Truncate errorDetails by UTF-8 byte limit and log truncation * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Vailence Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Mindbox.xcodeproj/project.pbxproj | 41 +----- .../String+Extensions.swift | 21 +++ .../InappShowFailureManager.swift | 23 +++- .../Extensions/StringExtensionsTests.swift | 17 +++ .../Tests/InappShowFailureManagerTests.swift | 129 ++++++++++++++++++ 5 files changed, 190 insertions(+), 41 deletions(-) rename Mindbox/{Validators => Extensions}/String+Extensions.swift (62%) diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index 026c0f197..68b62b075 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -1437,39 +1437,9 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - F385631E2DB6729000D91208 /* InappConfigurationDataFacade */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); - explicitFileTypes = { - }; - explicitFolders = ( - ); - path = InappConfigurationDataFacade; - sourceTree = ""; - }; - F397DE1C2CFF568800B72DA9 /* JSONs */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); - explicitFileTypes = { - }; - explicitFolders = ( - ); - path = JSONs; - sourceTree = ""; - }; - F3DEB38C2D47CBA200D0EFA4 /* InappSessionManagerTests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); - explicitFileTypes = { - }; - explicitFolders = ( - ); - path = InappSessionManagerTests; - sourceTree = ""; - }; + F385631E2DB6729000D91208 /* InappConfigurationDataFacade */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = InappConfigurationDataFacade; sourceTree = ""; }; + F397DE1C2CFF568800B72DA9 /* JSONs */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = JSONs; sourceTree = ""; }; + F3DEB38C2D47CBA200D0EFA4 /* InappSessionManagerTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = InappSessionManagerTests; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -2280,7 +2250,6 @@ F3BA5E000130A000C0000006 /* OperationsURLRoutingTests.swift */, F3CD202C2F600A800065392A /* HostNormalizerTests.swift */, ); - name = Network; path = Network; sourceTree = ""; }; @@ -2289,7 +2258,6 @@ children = ( 0475E8755F63483597539A50 /* TrackVisitManagerTests.swift */, ); - name = TrackVisitManager; path = TrackVisitManager; sourceTree = ""; }; @@ -2440,7 +2408,6 @@ F3A8B9912A3A408C00E9C055 /* SDKVersionValidator.swift */, F3A8B9992A3A471800E9C055 /* ABTestVariantsValidator.swift */, F3A8B99D2A3A4FD600E9C055 /* ABTestValidator.swift */, - F3482F292A65DCFC002A41EC /* String+Extensions.swift */, F34A45AF2B762A6100634C8B /* MindboxPushValidator.swift */, F31A947F2BC7E61800E6C978 /* InappFrequencyValidator.swift */, ); @@ -3016,7 +2983,6 @@ children = ( A1B2C3D4E5F6A7B8C9D0E1F3 /* MotionService.swift */, ); - name = Motion; path = Motion; sourceTree = ""; }; @@ -3591,6 +3557,7 @@ BB65630F2BE3BA430090C473 /* UIApplication+Extensions.swift */, F31DB4072F56A50E00DCEB85 /* NSError+Extensions.swift */, 326423031CA9C6BF0E62BEFD /* Date+Extensions.swift */, + F3482F292A65DCFC002A41EC /* String+Extensions.swift */, ); path = Extensions; sourceTree = ""; diff --git a/Mindbox/Validators/String+Extensions.swift b/Mindbox/Extensions/String+Extensions.swift similarity index 62% rename from Mindbox/Validators/String+Extensions.swift rename to Mindbox/Extensions/String+Extensions.swift index 1b48b8c5c..48cbf8155 100644 --- a/Mindbox/Validators/String+Extensions.swift +++ b/Mindbox/Extensions/String+Extensions.swift @@ -48,3 +48,24 @@ extension String { return hexValue.unicodeScalars.allSatisfy { hexCharacterSet.contains($0) } } } + +extension String { + /// Truncates the string so its UTF-8 byte representation does not exceed `limit`. + /// Cuts at extended grapheme cluster boundaries to keep multi-byte characters intact. + func truncated(toUTF8ByteLimit limit: Int) -> String { + guard limit >= 0 else { return "" } + guard utf8.count > limit else { return self } + + var result = "" + var byteCount = 0 + for character in self { + let characterByteCount = character.utf8.count + if byteCount + characterByteCount > limit { + break + } + byteCount += characterByteCount + result.append(character) + } + return result + } +} diff --git a/Mindbox/InAppMessages/InappShowFailureManager.swift b/Mindbox/InAppMessages/InappShowFailureManager.swift index 27b09e7f0..e35e0c80b 100644 --- a/Mindbox/InAppMessages/InappShowFailureManager.swift +++ b/Mindbox/InAppMessages/InappShowFailureManager.swift @@ -16,6 +16,9 @@ protocol InappShowFailureManagerProtocol { } final class InappShowFailureManager: InappShowFailureManagerProtocol { + /// Backend payload limit for errorDetails. + static let errorDetailsLimit = 1000 + private struct InAppShowFailuresBody: Codable { let failures: [InAppShowFailure] } @@ -36,7 +39,19 @@ final class InappShowFailureManager: InappShowFailureManagerProtocol { Logger.common(message: "[InappShowFailureManager] addFailure ignored, feature is disabled", category: .inAppMessages) return } - + + let truncatedDetails = details.map { original -> String in + let truncated = original.truncated(toUTF8ByteLimit: Self.errorDetailsLimit) + if truncated != original { + Logger.common( + message: "[InappShowFailureManager] errorDetails truncated to \(truncated.utf8.count) bytes (limit \(Self.errorDetailsLimit)). inappId=\(inappId)", + level: .debug, + category: .inAppMessages + ) + } + return truncated + } + queue.async { [self] in if let existingIndex = failures.firstIndex(where: { $0.inappId == inappId }) { guard shouldReplaceFailure(currentReason: failures[existingIndex].failureReason, newReason: reason) else { @@ -48,13 +63,13 @@ final class InappShowFailureManager: InappShowFailureManagerProtocol { ) return } - failures[existingIndex] = makeFailure(inappId: inappId, reason: reason, details: details) + failures[existingIndex] = makeFailure(inappId: inappId, reason: reason, details: truncatedDetails) Logger.common(message: "[InappShowFailureManager] Failure reason updated. inappId=\(inappId), reason=\(reason.rawValue)", category: .inAppMessages) return } - - failures.append(makeFailure(inappId: inappId, reason: reason, details: details)) + + failures.append(makeFailure(inappId: inappId, reason: reason, details: truncatedDetails)) } } diff --git a/MindboxTests/Extensions/StringExtensionsTests.swift b/MindboxTests/Extensions/StringExtensionsTests.swift index 6abdda11c..1485bb4ca 100644 --- a/MindboxTests/Extensions/StringExtensionsTests.swift +++ b/MindboxTests/Extensions/StringExtensionsTests.swift @@ -58,6 +58,23 @@ final class StringExtensionsTests: XCTestCase { } } + func test_truncatedToUTF8ByteLimit_zeroLimit_returnsEmptyString() { + XCTAssertEqual("hello".truncated(toUTF8ByteLimit: 0), "") + } + + func test_truncatedToUTF8ByteLimit_zeroLimit_onEmptyString_returnsEmptyString() { + XCTAssertEqual("".truncated(toUTF8ByteLimit: 0), "") + } + + func test_truncatedToUTF8ByteLimit_negativeLimit_returnsEmptyString() { + XCTAssertEqual("hello".truncated(toUTF8ByteLimit: -1), "") + XCTAssertEqual("hello".truncated(toUTF8ByteLimit: -100), "") + } + + func test_truncatedToUTF8ByteLimit_negativeLimit_onEmptyString_returnsEmptyString() { + XCTAssertEqual("".truncated(toUTF8ByteLimit: -1), "") + } + func test_parseTimeSpanToMillisNegative() throws { let testCases: Array = [ "12345678901234567890.00:00:00.00", diff --git a/MindboxTests/InApp/Tests/InappShowFailureManagerTests.swift b/MindboxTests/InApp/Tests/InappShowFailureManagerTests.swift index a1defff9e..0e670d3c7 100644 --- a/MindboxTests/InApp/Tests/InappShowFailureManagerTests.swift +++ b/MindboxTests/InApp/Tests/InappShowFailureManagerTests.swift @@ -200,6 +200,135 @@ final class InappShowFailureManagerTests: XCTestCase { assertCreatedEventsCountEventually(0) } + func testAddFailure_errorDetailsBelowLimit_isNotTruncated() throws { + let details = String(repeating: "a", count: InappShowFailureManager.errorDetailsLimit - 1) + + manager.addFailure(inappId: "inapp-below-limit", reason: .unknownError, details: details) + manager.sendFailures() + + assertCreatedEventsCountEventually(1) + let event = try XCTUnwrap(databaseRepository.createdEvents.first) + let failure = try XCTUnwrap(decodeFailures(from: event)?.first) + XCTAssertEqual(failure.errorDetails?.count, InappShowFailureManager.errorDetailsLimit - 1) + XCTAssertEqual(failure.errorDetails, details) + } + + func testAddFailure_errorDetailsAtLimit_isNotTruncated() throws { + let details = String(repeating: "b", count: InappShowFailureManager.errorDetailsLimit) + + manager.addFailure(inappId: "inapp-at-limit", reason: .unknownError, details: details) + manager.sendFailures() + + assertCreatedEventsCountEventually(1) + let event = try XCTUnwrap(databaseRepository.createdEvents.first) + let failure = try XCTUnwrap(decodeFailures(from: event)?.first) + XCTAssertEqual(failure.errorDetails?.count, InappShowFailureManager.errorDetailsLimit) + XCTAssertEqual(failure.errorDetails, details) + } + + func testAddFailure_errorDetailsAboveLimit_isTruncatedToLimit() throws { + let limit = InappShowFailureManager.errorDetailsLimit + let details = String(repeating: "c", count: limit + 500) + + manager.addFailure(inappId: "inapp-above-limit", reason: .unknownError, details: details) + manager.sendFailures() + + assertCreatedEventsCountEventually(1) + let event = try XCTUnwrap(databaseRepository.createdEvents.first) + let failure = try XCTUnwrap(decodeFailures(from: event)?.first) + XCTAssertEqual(failure.errorDetails?.count, limit) + XCTAssertEqual(failure.errorDetails, String(details.prefix(limit))) + } + + func testAddFailure_errorDetailsNil_remainsNil() throws { + manager.addFailure(inappId: "inapp-nil-details", reason: .unknownError, details: nil) + manager.sendFailures() + + assertCreatedEventsCountEventually(1) + let event = try XCTUnwrap(databaseRepository.createdEvents.first) + let failure = try XCTUnwrap(decodeFailures(from: event)?.first) + XCTAssertNil(failure.errorDetails) + } + + func testAddFailure_errorDetailsEmpty_remainsEmpty() throws { + manager.addFailure(inappId: "inapp-empty-details", reason: .unknownError, details: "") + manager.sendFailures() + + assertCreatedEventsCountEventually(1) + let event = try XCTUnwrap(databaseRepository.createdEvents.first) + let failure = try XCTUnwrap(decodeFailures(from: event)?.first) + XCTAssertEqual(failure.errorDetails, "") + } + + func testAddFailure_errorDetailsMultibyte_truncatesByUTF8Bytes() throws { + let limit = InappShowFailureManager.errorDetailsLimit + // Cyrillic 'а' is 2 bytes in UTF-8: total = 2 * limit bytes. + let details = String(repeating: "а", count: limit) + + manager.addFailure(inappId: "inapp-multibyte", reason: .unknownError, details: details) + manager.sendFailures() + + assertCreatedEventsCountEventually(1) + let event = try XCTUnwrap(databaseRepository.createdEvents.first) + let failure = try XCTUnwrap(decodeFailures(from: event)?.first) + let truncated = try XCTUnwrap(failure.errorDetails) + + XCTAssertEqual(truncated.utf8.count, limit) + XCTAssertEqual(truncated.count, limit / 2) + } + + func testAddFailure_errorDetailsMultibyte_doesNotSplitCharacter() throws { + let limit = InappShowFailureManager.errorDetailsLimit + let asciiPrefix = String(repeating: "x", count: limit - 1) + // Cyrillic 'ё' is 2 bytes — appending it would overflow by 1 byte. + let details = asciiPrefix + "ё" + + manager.addFailure(inappId: "inapp-no-split", reason: .unknownError, details: details) + manager.sendFailures() + + assertCreatedEventsCountEventually(1) + let event = try XCTUnwrap(databaseRepository.createdEvents.first) + let failure = try XCTUnwrap(decodeFailures(from: event)?.first) + let truncated = try XCTUnwrap(failure.errorDetails) + + XCTAssertEqual(truncated, asciiPrefix) + XCTAssertEqual(truncated.utf8.count, limit - 1) + } + + func testAddFailure_errorDetailsEmoji_isNotSplit() throws { + let limit = InappShowFailureManager.errorDetailsLimit + // "🙂" is 4 UTF-8 bytes. Fill almost to the limit, then append an emoji. + let asciiPrefix = String(repeating: "y", count: limit - 2) + let details = asciiPrefix + "🙂" + + manager.addFailure(inappId: "inapp-emoji", reason: .unknownError, details: details) + manager.sendFailures() + + assertCreatedEventsCountEventually(1) + let event = try XCTUnwrap(databaseRepository.createdEvents.first) + let failure = try XCTUnwrap(decodeFailures(from: event)?.first) + let truncated = try XCTUnwrap(failure.errorDetails) + + XCTAssertEqual(truncated, asciiPrefix) + XCTAssertLessThanOrEqual(truncated.utf8.count, limit) + } + + func testAddFailure_priorityReplacement_truncatesNewDetails() throws { + let limit = InappShowFailureManager.errorDetailsLimit + let longDetails = String(repeating: "d", count: limit + 200) + + manager.addFailure(inappId: "inapp-priority-truncate", reason: .productSegmentRequestFailed, details: "short") + manager.addFailure(inappId: "inapp-priority-truncate", reason: .customerSegmentRequestFailed, details: longDetails) + manager.sendFailures() + + assertCreatedEventsCountEventually(1) + let event = try XCTUnwrap(databaseRepository.createdEvents.first) + let failure = try XCTUnwrap(decodeFailures(from: event)?.first) + XCTAssertEqual(failure.failureReason, .customerSegmentRequestFailed) + XCTAssertEqual(failure.errorDetails?.count, limit) + XCTAssertEqual(failure.errorDetails, String(longDetails.prefix(limit))) + } + func testSendFailures_whenFeatureDisabled_doesNotSendAndKeepsBufferedFailures() throws { manager.addFailure( inappId: "inapp-toggle-disabled", From ce1957bfed865d47c9f030eb0f2d27709a0ee604 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Thu, 14 May 2026 13:34:02 +0300 Subject: [PATCH 22/24] Merge pull request #704 from mindbox-cloud/feature/MOBILE-180 MOBILE-180: Trim `operationsDomain` config value before blank check --- .../OperationsDomainConfigPolicy.swift | 3 ++- .../Network/OperationsURLRoutingTests.swift | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift index f1b456c5f..960ad9b93 100644 --- a/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift +++ b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift @@ -27,7 +27,8 @@ enum OperationsDomainConfigPolicy { } static func action(for raw: String?, currentlyStored: String?) -> Action { - guard let value = raw, !value.isEmpty else { + let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let value = trimmed, !value.isEmpty else { return currentlyStored == nil ? .keep : .clear } diff --git a/MindboxTests/Network/OperationsURLRoutingTests.swift b/MindboxTests/Network/OperationsURLRoutingTests.swift index 2407c5428..230b9a85a 100644 --- a/MindboxTests/Network/OperationsURLRoutingTests.swift +++ b/MindboxTests/Network/OperationsURLRoutingTests.swift @@ -278,10 +278,36 @@ struct OperationsURLRoutingTests { #expect(OperationsDomainConfigPolicy.action(for: "", currentlyStored: "https://old.ru") == .clear) } + @Test("Policy — clears on whitespace-only string when something is stored (Android parity)") + func policyClearsOnWhitespaceWhenStored() { + #expect(OperationsDomainConfigPolicy.action(for: " ", currentlyStored: "https://old.ru") == .clear) + #expect(OperationsDomainConfigPolicy.action(for: "\t\n ", currentlyStored: "https://old.ru") == .clear) + } + @Test("Policy — no-ops when nothing stored and nothing came") func policyKeepsOnNothingToChange() { #expect(OperationsDomainConfigPolicy.action(for: nil, currentlyStored: nil) == .keep) #expect(OperationsDomainConfigPolicy.action(for: "", currentlyStored: nil) == .keep) + #expect(OperationsDomainConfigPolicy.action(for: " ", currentlyStored: nil) == .keep) + } + + @Test("Policy — trims surrounding whitespace before evaluating value") + func policyTrimsSurroundingWhitespace() { + // Trimmed value matches stored after canonicalization → no-op. + #expect( + OperationsDomainConfigPolicy.action(for: " anonymizer.client.ru ", currentlyStored: "https://anonymizer.client.ru") + == .keep + ) + // Trimmed value differs from stored → save canonical form. + #expect( + OperationsDomainConfigPolicy.action(for: " new.client.ru ", currentlyStored: "https://old.ru") + == .save("https://new.client.ru") + ) + // Trimmed value with nothing stored → save canonical form. + #expect( + OperationsDomainConfigPolicy.action(for: " valid.host.ru ", currentlyStored: nil) + == .save("https://valid.host.ru") + ) } @Test("Policy — rejects format-broken incoming value (previous kept intact)") From 6e5bde542786b089914aa8d2d1ab6dcafbcd4d09 Mon Sep 17 00:00:00 2001 From: Akylbek Utekeshev Date: Thu, 14 May 2026 16:44:22 +0500 Subject: [PATCH 23/24] MOBILE-164: Forward raw 2xx sync-operation body to WebView JS (#703) * MOBILE-164-Sync-send-error-raw * MOBILE-164: Add tests for raw sync-operation pass-through Cover MBNetworkFetcher.requestRaw, MBEventRepository.sendRaw, and the WebView sync-operation response shaping. Extract a pure helper TransparentView.makeSyncOperationResponse so the JS-bridge contract is unit-testable without spinning up WKWebView. * MOBILE-164: Address review feedback - MBNetworkFetcher.requestRaw: capture self strongly so completion is always invoked even if the fetcher were deallocated mid-request (matches the existing `request` pattern). - TransparentView.handleSyncOperation: build the outgoing BridgeMessage first, then log based on its `type` so the non-UTF-8 fallback path reports "failed" instead of "success". --------- Co-authored-by: Vailence --- Mindbox.xcodeproj/project.pbxproj | 126 +++++++---- .../Views/WebView/TransparentView.swift | 76 +++++-- Mindbox/Network/Abstract/NetworkFetcher.swift | 9 + Mindbox/Network/MBNetworkFetcher.swift | 41 ++++ .../Event/EventRepository.swift | 8 +- .../Event/MBEventRepository.swift | 35 +++ ...parentViewSyncOperationResponseTests.swift | 177 +++++++++++++++ .../Mocks/EventRepositoryMock.swift | 6 +- .../Mock/MockFailureNetworkFetcher.swift | 14 +- MindboxTests/Mock/MockNetworkFetcher.swift | 10 +- .../MBEventRepositorySendRawTests.swift | 211 ++++++++++++++++++ ...BNetworkFetcherResponseHandlingTests.swift | 160 +++++++++++++ 12 files changed, 803 insertions(+), 70 deletions(-) create mode 100644 MindboxTests/InApp/Tests/TransparentViewSyncOperationResponseTests.swift create mode 100644 MindboxTests/Network/MBEventRepositorySendRawTests.swift diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index 68b62b075..cf85e01b1 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -11,13 +11,11 @@ 0E7A224A082FA2DA35706CC7 /* MotionServiceResolvePositionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8192B8B7043EF74D05B36B /* MotionServiceResolvePositionTests.swift */; }; 0E7A224A082FA2DA35706CC8 /* MotionServiceShakeToEditTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8192B8B7043EF74D05B36C /* MotionServiceShakeToEditTests.swift */; }; 1E3BD63AB3F1521C253CB818 /* MBNetworkFetcherResponseHandlingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97FEDDEB5F71A67F1C4C675F /* MBNetworkFetcherResponseHandlingTests.swift */; }; - F3BA5E000130A000C0000005 /* OperationsURLRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BA5E000130A000C0000006 /* OperationsURLRoutingTests.swift */; }; 302E35788CBDA959283569F4 /* MotionServiceBehaviorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0DB93A7997961CA7C2BE917 /* MotionServiceBehaviorTests.swift */; }; 313B233A25ADEA0F00A1CB72 /* Mindbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 313B233025ADEA0F00A1CB72 /* Mindbox.framework */; }; 313B233F25ADEA0F00A1CB72 /* MindboxTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 313B233E25ADEA0F00A1CB72 /* MindboxTests.swift */; }; 313B234125ADEA0F00A1CB72 /* Mindbox.h in Headers */ = {isa = PBXBuildFile; fileRef = 313B233325ADEA0F00A1CB72 /* Mindbox.h */; settings = {ATTRIBUTES = (Public, ); }; }; 314B38FD25AEE8B200E947B9 /* MBConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B38FC25AEE8B200E947B9 /* MBConfiguration.swift */; }; - F3BA5E000130A000C0000003 /* BaseAddressesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BA5E000130A000C0000004 /* BaseAddressesModel.swift */; }; 314B390025AEE96F00E947B9 /* CoreController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B38FF25AEE96F00E947B9 /* CoreController.swift */; }; 317054CB25AF189800AE624C /* PersistenceStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317054CA25AF189800AE624C /* PersistenceStorage.swift */; }; 317AF8FC25B844DB006348FA /* UtilitiesFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317AF8FB25B844DB006348FA /* UtilitiesFetcher.swift */; }; @@ -28,8 +26,6 @@ 31A20D4E25B6EFB600AAA0A3 /* MindboxDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31A20D4D25B6EFB600AAA0A3 /* MindboxDelegate.swift */; }; 31EB907325C402F900368FFB /* TestConfig3.plist in Resources */ = {isa = PBXBuildFile; fileRef = 31EB907125C402F900368FFB /* TestConfig3.plist */; }; 31EB907425C402F900368FFB /* TestConfig2.plist in Resources */ = {isa = PBXBuildFile; fileRef = 31EB907225C402F900368FFB /* TestConfig2.plist */; }; - F3CD20262F600A800065392A /* MBConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CD20272F600A800065392A /* MBConfigurationTests.swift */; }; - F3CD202B2F600A800065392A /* HostNormalizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CD202C2F600A800065392A /* HostNormalizerTests.swift */; }; 31ED2DF225C4456600301FAD /* TestConfig_Invalid_2.plist in Resources */ = {isa = PBXBuildFile; fileRef = 31ED2DEF25C4456600301FAD /* TestConfig_Invalid_2.plist */; }; 31ED2DF325C4456600301FAD /* TestConfig_Invalid_1.plist in Resources */ = {isa = PBXBuildFile; fileRef = 31ED2DF025C4456600301FAD /* TestConfig_Invalid_1.plist */; }; 31ED2DF425C4456600301FAD /* TestConfig_Invalid_3.plist in Resources */ = {isa = PBXBuildFile; fileRef = 31ED2DF125C4456600301FAD /* TestConfig_Invalid_3.plist */; }; @@ -58,7 +54,6 @@ 3333C1B22681D42000B60D84 /* Payload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3333C1B12681D42000B60D84 /* Payload.swift */; }; 3333C1B42681D43C00B60D84 /* ImageFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3333C1B32681D43C00B60D84 /* ImageFormat.swift */; }; 3333C1DE2681E9F300B60D84 /* URLRequestBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3333C1DD2681E9F300B60D84 /* URLRequestBuilder.swift */; }; - F3CD20292F600A800065392A /* HostNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CD202A2F600A800065392A /* HostNormalizer.swift */; }; 3333C1E12681EA4D00B60D84 /* NotificationsPayloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3333C1E02681EA4C00B60D84 /* NotificationsPayloads.swift */; }; 3333D7BE265E56F2004279B0 /* OperationResponseType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3333D7BD265E56F2004279B0 /* OperationResponseType.swift */; }; 3337E6A3265FAB39006949EB /* BaseResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3337E6A2265FAB39006949EB /* BaseResponse.swift */; }; @@ -313,7 +308,6 @@ 84B625E425C988FA00AB6228 /* URLValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B625E325C988FA00AB6228 /* URLValidator.swift */; }; 84B625E925C989C100AB6228 /* UDIDValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B625E825C989C100AB6228 /* UDIDValidator.swift */; }; 84B625F025C98B1200AB6228 /* ValidatorsTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B625EF25C98B1200AB6228 /* ValidatorsTestCase.swift */; }; - F3CD202D2F600A800065392A /* URLValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CD202E2F600A800065392A /* URLValidatorTests.swift */; }; 84BAEF8225D54919002E8A26 /* BodyDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84BAEF8125D54919002E8A26 /* BodyDecoder.swift */; }; 84C65E5E25D4FBA3008996FA /* MobileApplicationInstalled.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C65E5D25D4FBA3008996FA /* MobileApplicationInstalled.swift */; }; 84C65E6425D4FBBB008996FA /* MobileApplicationInfoUpdated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C65E6325D4FBBB008996FA /* MobileApplicationInfoUpdated.swift */; }; @@ -440,6 +434,7 @@ A1D017F22976CC9400CD9F99 /* SegmentTargeting.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D017F12976CC9400CD9F99 /* SegmentTargeting.swift */; }; A1D017F52976FC2B00CD9F99 /* InternalTargetingChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D017F42976FC2B00CD9F99 /* InternalTargetingChecker.swift */; }; A1D23AF029DE082E00A75179 /* InAppProductSegmentResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D23AEF29DE082E00A75179 /* InAppProductSegmentResponse.swift */; }; + AF174B2121221D323FB95EF0 /* MBEventRepositorySendRawTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BECF3D292B29C1894F80948F /* MBEventRepositorySendRawTests.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 */; }; @@ -467,6 +462,7 @@ D2F7E24A2BADC2AB00B24BB8 /* SessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2F7E2492BADC2AB00B24BB8 /* SessionManager.swift */; }; D2F7E24C2BADC4CA00B24BB8 /* MockSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2F7E24B2BADC4CA00B24BB8 /* MockSessionManager.swift */; }; DEC482157E5249DBBFAEFC9A /* FeatureTogglesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9778038796A8426ABDED1E97 /* FeatureTogglesModel.swift */; }; + EA395B77BB16CEFE6DC91D1D /* TransparentViewSyncOperationResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B7DAFAB687945FA908DB1AC /* TransparentViewSyncOperationResponseTests.swift */; }; F26DFF81C3FF57C3DE68DEDC /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 326423031CA9C6BF0E62BEFD /* Date+Extensions.swift */; }; F30005442CFF3F7D004BE915 /* ABTestStubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = F30005432CFF3F7D004BE915 /* ABTestStubs.swift */; }; F306291A2BD27D7500EF6609 /* InappFrequencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F30629192BD27D7500EF6609 /* InappFrequencyTests.swift */; }; @@ -482,7 +478,6 @@ F31470962B96681F00E01E5C /* 27-TargetingRequests.json in Resources */ = {isa = PBXBuildFile; fileRef = F31470952B96681F00E01E5C /* 27-TargetingRequests.json */; }; F31470982B9668F100E01E5C /* 31-TargetingRequests.json in Resources */ = {isa = PBXBuildFile; fileRef = F31470972B9668F100E01E5C /* 31-TargetingRequests.json */; }; F315503F2BBB24E20072A071 /* TTLValidationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F315503E2BBB24E20072A071 /* TTLValidationService.swift */; }; - F3BA5E000130A000C0000007 /* OperationsDomainConfigPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BA5E000130A000C0000008 /* OperationsDomainConfigPolicy.swift */; }; F31909992E979D9E00373E2F /* MindboxAppDelegateProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31909982E979D9E00373E2F /* MindboxAppDelegateProxy.swift */; }; F31A94782BC6995500E6C978 /* InappFrequency.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31A94772BC6995500E6C978 /* InappFrequency.swift */; }; F31A947C2BC69E3900E6C978 /* PeriodicFrequency.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31A947B2BC69E3900E6C978 /* PeriodicFrequency.swift */; }; @@ -564,10 +559,6 @@ F34A10462F455C5B0065392A /* SettingsFeatureTogglesError.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A103E2F455C5B0065392A /* SettingsFeatureTogglesError.json */; }; F34A10472F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A10412F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json */; }; F34A10482F455C5B0065392A /* SettingsFeatureTogglesTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A10422F455C5B0065392A /* SettingsFeatureTogglesTypeError.json */; }; - F3BA10552F500A800065392A /* SettingsBaseAddressesError.json in Resources */ = {isa = PBXBuildFile; fileRef = F3BA10512F500A800065392A /* SettingsBaseAddressesError.json */; }; - F3BA10562F500A800065392A /* SettingsBaseAddressesTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = F3BA10522F500A800065392A /* SettingsBaseAddressesTypeError.json */; }; - F3BA10572F500A800065392A /* SettingsBaseAddressesOperationsError.json in Resources */ = {isa = PBXBuildFile; fileRef = F3BA10532F500A800065392A /* SettingsBaseAddressesOperationsError.json */; }; - F3BA10582F500A800065392A /* SettingsBaseAddressesOperationsTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = F3BA10542F500A800065392A /* SettingsBaseAddressesOperationsTypeError.json */; }; F34A45AE2B7628B700634C8B /* MBPushNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = F34A45AD2B7628B700634C8B /* MBPushNotification.swift */; }; F34A45B02B762A6100634C8B /* MindboxPushValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F34A45AF2B762A6100634C8B /* MindboxPushValidator.swift */; }; F351F1C02CE380A40053423E /* InappMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = F351F1BF2CE380A40053423E /* InappMapper.swift */; }; @@ -659,11 +650,22 @@ F3B70A012F250A0100AABB01 /* ForegroundStopwatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3B70A002F250A0100AABB01 /* ForegroundStopwatch.swift */; }; F3B70A032F250A0100AABB02 /* ForegroundStopwatchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3B70A022F250A0100AABB02 /* ForegroundStopwatchTests.swift */; }; F3B70A052F250A0100AABB03 /* TimeToDisplayBackgroundTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3B70A042F250A0100AABB03 /* TimeToDisplayBackgroundTests.swift */; }; + F3BA10552F500A800065392A /* SettingsBaseAddressesError.json in Resources */ = {isa = PBXBuildFile; fileRef = F3BA10512F500A800065392A /* SettingsBaseAddressesError.json */; }; + F3BA10562F500A800065392A /* SettingsBaseAddressesTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = F3BA10522F500A800065392A /* SettingsBaseAddressesTypeError.json */; }; + F3BA10572F500A800065392A /* SettingsBaseAddressesOperationsError.json in Resources */ = {isa = PBXBuildFile; fileRef = F3BA10532F500A800065392A /* SettingsBaseAddressesOperationsError.json */; }; + F3BA10582F500A800065392A /* SettingsBaseAddressesOperationsTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = F3BA10542F500A800065392A /* SettingsBaseAddressesOperationsTypeError.json */; }; + F3BA5E000130A000C0000003 /* BaseAddressesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BA5E000130A000C0000004 /* BaseAddressesModel.swift */; }; + F3BA5E000130A000C0000005 /* OperationsURLRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BA5E000130A000C0000006 /* OperationsURLRoutingTests.swift */; }; + F3BA5E000130A000C0000007 /* OperationsDomainConfigPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BA5E000130A000C0000008 /* OperationsDomainConfigPolicy.swift */; }; F3BD9F822F273BCD00647BAF /* BridgeMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BD9F812F273BCD00647BAF /* BridgeMessage.swift */; }; F3BDA0382F27444500647BAF /* MindboxWebBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BDA0372F27444500647BAF /* MindboxWebBridge.swift */; }; F3C1A0022F5B100100ABC001 /* InappShowFailureManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C1A0012F5B100100ABC001 /* InappShowFailureManager.swift */; }; F3C1A0042F5B100100ABC001 /* InAppShowFailure.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C1A0032F5B100100ABC001 /* InAppShowFailure.swift */; }; F3C1A0062F5B100100ABC001 /* InappShowFailureManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C1A0052F5B100100ABC001 /* InappShowFailureManagerTests.swift */; }; + F3CD20262F600A800065392A /* MBConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CD20272F600A800065392A /* MBConfigurationTests.swift */; }; + F3CD20292F600A800065392A /* HostNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CD202A2F600A800065392A /* HostNormalizer.swift */; }; + F3CD202B2F600A800065392A /* HostNormalizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CD202C2F600A800065392A /* HostNormalizerTests.swift */; }; + F3CD202D2F600A800065392A /* URLValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CD202E2F600A800065392A /* URLValidatorTests.swift */; }; F3D818B02A3885AD0002957C /* ABTestDeviceMixer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3D818AF2A3885AD0002957C /* ABTestDeviceMixer.swift */; }; F3D818B32A3885F50002957C /* ABTestDeviceMixerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3D818B22A3885F50002957C /* ABTestDeviceMixerTests.swift */; }; F3D925AB2A120C0F00135C87 /* InAppImageDownloaderMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3D925AA2A120C0F00135C87 /* InAppImageDownloaderMock.swift */; }; @@ -749,7 +751,6 @@ 313B233E25ADEA0F00A1CB72 /* MindboxTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindboxTests.swift; sourceTree = ""; }; 313B234025ADEA0F00A1CB72 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 314B38FC25AEE8B200E947B9 /* MBConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MBConfiguration.swift; sourceTree = ""; }; - F3BA5E000130A000C0000004 /* BaseAddressesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseAddressesModel.swift; sourceTree = ""; }; 314B38FF25AEE96F00E947B9 /* CoreController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreController.swift; sourceTree = ""; }; 317054CA25AF189800AE624C /* PersistenceStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceStorage.swift; sourceTree = ""; }; 317AF8FB25B844DB006348FA /* UtilitiesFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UtilitiesFetcher.swift; sourceTree = ""; }; @@ -760,8 +761,6 @@ 31A20D4D25B6EFB600AAA0A3 /* MindboxDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindboxDelegate.swift; sourceTree = ""; }; 31EB907125C402F900368FFB /* TestConfig3.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig3.plist; sourceTree = ""; }; 31EB907225C402F900368FFB /* TestConfig2.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig2.plist; sourceTree = ""; }; - F3CD20272F600A800065392A /* MBConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MBConfigurationTests.swift; sourceTree = ""; }; - F3CD202C2F600A800065392A /* HostNormalizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostNormalizerTests.swift; sourceTree = ""; }; 31ED2DEF25C4456600301FAD /* TestConfig_Invalid_2.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig_Invalid_2.plist; sourceTree = ""; }; 31ED2DF025C4456600301FAD /* TestConfig_Invalid_1.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig_Invalid_1.plist; sourceTree = ""; }; 31ED2DF125C4456600301FAD /* TestConfig_Invalid_3.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig_Invalid_3.plist; sourceTree = ""; }; @@ -793,7 +792,6 @@ 3333C1B12681D42000B60D84 /* Payload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Payload.swift; sourceTree = ""; }; 3333C1B32681D43C00B60D84 /* ImageFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageFormat.swift; sourceTree = ""; }; 3333C1DD2681E9F300B60D84 /* URLRequestBuilder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLRequestBuilder.swift; sourceTree = ""; }; - F3CD202A2F600A800065392A /* HostNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostNormalizer.swift; sourceTree = ""; }; 3333C1E02681EA4C00B60D84 /* NotificationsPayloads.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationsPayloads.swift; sourceTree = ""; }; 3333D7BD265E56F2004279B0 /* OperationResponseType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationResponseType.swift; sourceTree = ""; }; 3337E6A2265FAB39006949EB /* BaseResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseResponse.swift; sourceTree = ""; }; @@ -987,6 +985,7 @@ 47EFF0FC2E8D85B700E72D0A /* DatabaseMetadataMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseMetadataMigrationTests.swift; sourceTree = ""; }; 47FDF0B92C5BDAB80051F08C /* MigrationManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationManagerProtocol.swift; sourceTree = ""; }; 47FDF0BB2C5BE8BB0051F08C /* MigrationProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationProtocol.swift; sourceTree = ""; }; + 4B7DAFAB687945FA908DB1AC /* TransparentViewSyncOperationResponseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransparentViewSyncOperationResponseTests.swift; sourceTree = ""; }; 6F1EAA15266A670E007A335B /* ProductListItemsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductListItemsResponse.swift; sourceTree = ""; }; 6FDD143A266F7BD900A50C35 /* ProcessingStatusResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessingStatusResponse.swift; sourceTree = ""; }; 6FDD143C266F7BEB00A50C35 /* ItemResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemResponse.swift; sourceTree = ""; }; @@ -1040,7 +1039,6 @@ 84B625E325C988FA00AB6228 /* URLValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLValidator.swift; sourceTree = ""; }; 84B625E825C989C100AB6228 /* UDIDValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UDIDValidator.swift; sourceTree = ""; }; 84B625EF25C98B1200AB6228 /* ValidatorsTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidatorsTestCase.swift; sourceTree = ""; }; - F3CD202E2F600A800065392A /* URLValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLValidatorTests.swift; sourceTree = ""; }; 84BAEF8125D54919002E8A26 /* BodyDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BodyDecoder.swift; sourceTree = ""; }; 84C65E5D25D4FBA3008996FA /* MobileApplicationInstalled.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileApplicationInstalled.swift; sourceTree = ""; }; 84C65E6325D4FBBB008996FA /* MobileApplicationInfoUpdated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileApplicationInfoUpdated.swift; sourceTree = ""; }; @@ -1070,7 +1068,6 @@ 84FCD3BC25CA10F600D1E574 /* SuccessResponse.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = SuccessResponse.json; sourceTree = ""; }; 9778038796A8426ABDED1E97 /* FeatureTogglesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureTogglesModel.swift; sourceTree = ""; }; 97FEDDEB5F71A67F1C4C675F /* MBNetworkFetcherResponseHandlingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MBNetworkFetcherResponseHandlingTests.swift; sourceTree = ""; }; - F3BA5E000130A000C0000006 /* OperationsURLRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationsURLRoutingTests.swift; sourceTree = ""; }; 9B24FAAB28C74B8300F10B5D /* InAppConfigurationRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppConfigurationRepository.swift; sourceTree = ""; }; 9B24FAAD28C74BA500F10B5D /* InAppCoreManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppCoreManager.swift; sourceTree = ""; }; 9B24FAB028C74BD200F10B5D /* InAppConfigurationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppConfigurationManager.swift; sourceTree = ""; }; @@ -1197,6 +1194,7 @@ BD1BE43AA9EAEA03F8ED400B /* HapticRequestValidator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HapticRequestValidator.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; D2F7E2412BADB89900B24BB8 /* UserVisitManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserVisitManager.swift; sourceTree = ""; }; @@ -1218,7 +1216,6 @@ F31470952B96681F00E01E5C /* 27-TargetingRequests.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "27-TargetingRequests.json"; sourceTree = ""; }; F31470972B9668F100E01E5C /* 31-TargetingRequests.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "31-TargetingRequests.json"; sourceTree = ""; }; F315503E2BBB24E20072A071 /* TTLValidationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTLValidationService.swift; sourceTree = ""; }; - F3BA5E000130A000C0000008 /* OperationsDomainConfigPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationsDomainConfigPolicy.swift; sourceTree = ""; }; F31909982E979D9E00373E2F /* MindboxAppDelegateProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindboxAppDelegateProxy.swift; sourceTree = ""; }; F31A94772BC6995500E6C978 /* InappFrequency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InappFrequency.swift; sourceTree = ""; }; F31A947B2BC69E3900E6C978 /* PeriodicFrequency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeriodicFrequency.swift; sourceTree = ""; }; @@ -1300,10 +1297,6 @@ F34A10402F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorMissing.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesShouldSendInAppShowErrorMissing.json; sourceTree = ""; }; F34A10412F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json; sourceTree = ""; }; F34A10422F455C5B0065392A /* SettingsFeatureTogglesTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesTypeError.json; sourceTree = ""; }; - F3BA10512F500A800065392A /* SettingsBaseAddressesError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsBaseAddressesError.json; sourceTree = ""; }; - F3BA10522F500A800065392A /* SettingsBaseAddressesTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsBaseAddressesTypeError.json; sourceTree = ""; }; - F3BA10532F500A800065392A /* SettingsBaseAddressesOperationsError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsBaseAddressesOperationsError.json; sourceTree = ""; }; - F3BA10542F500A800065392A /* SettingsBaseAddressesOperationsTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsBaseAddressesOperationsTypeError.json; sourceTree = ""; }; F34A45AD2B7628B700634C8B /* MBPushNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MBPushNotification.swift; sourceTree = ""; }; F34A45AF2B762A6100634C8B /* MindboxPushValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindboxPushValidator.swift; sourceTree = ""; }; F351F1BF2CE380A40053423E /* InappMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InappMapper.swift; sourceTree = ""; }; @@ -1395,11 +1388,22 @@ F3B70A002F250A0100AABB01 /* ForegroundStopwatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForegroundStopwatch.swift; sourceTree = ""; }; F3B70A022F250A0100AABB02 /* ForegroundStopwatchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForegroundStopwatchTests.swift; sourceTree = ""; }; F3B70A042F250A0100AABB03 /* TimeToDisplayBackgroundTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeToDisplayBackgroundTests.swift; sourceTree = ""; }; + F3BA10512F500A800065392A /* SettingsBaseAddressesError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsBaseAddressesError.json; sourceTree = ""; }; + F3BA10522F500A800065392A /* SettingsBaseAddressesTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsBaseAddressesTypeError.json; sourceTree = ""; }; + F3BA10532F500A800065392A /* SettingsBaseAddressesOperationsError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsBaseAddressesOperationsError.json; sourceTree = ""; }; + F3BA10542F500A800065392A /* SettingsBaseAddressesOperationsTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsBaseAddressesOperationsTypeError.json; sourceTree = ""; }; + F3BA5E000130A000C0000004 /* BaseAddressesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseAddressesModel.swift; sourceTree = ""; }; + F3BA5E000130A000C0000006 /* OperationsURLRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationsURLRoutingTests.swift; sourceTree = ""; }; + F3BA5E000130A000C0000008 /* OperationsDomainConfigPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationsDomainConfigPolicy.swift; sourceTree = ""; }; F3BD9F812F273BCD00647BAF /* BridgeMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BridgeMessage.swift; sourceTree = ""; }; F3BDA0372F27444500647BAF /* MindboxWebBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindboxWebBridge.swift; sourceTree = ""; }; F3C1A0012F5B100100ABC001 /* InappShowFailureManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InappShowFailureManager.swift; sourceTree = ""; }; F3C1A0032F5B100100ABC001 /* InAppShowFailure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppShowFailure.swift; sourceTree = ""; }; F3C1A0052F5B100100ABC001 /* InappShowFailureManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InappShowFailureManagerTests.swift; sourceTree = ""; }; + F3CD20272F600A800065392A /* MBConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MBConfigurationTests.swift; sourceTree = ""; }; + F3CD202A2F600A800065392A /* HostNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostNormalizer.swift; sourceTree = ""; }; + F3CD202C2F600A800065392A /* HostNormalizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostNormalizerTests.swift; sourceTree = ""; }; + F3CD202E2F600A800065392A /* URLValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLValidatorTests.swift; sourceTree = ""; }; F3D818AF2A3885AD0002957C /* ABTestDeviceMixer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ABTestDeviceMixer.swift; sourceTree = ""; }; F3D818B22A3885F50002957C /* ABTestDeviceMixerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ABTestDeviceMixerTests.swift; sourceTree = ""; }; F3D925AA2A120C0F00135C87 /* InAppImageDownloaderMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppImageDownloaderMock.swift; sourceTree = ""; }; @@ -1437,9 +1441,39 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - F385631E2DB6729000D91208 /* InappConfigurationDataFacade */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = InappConfigurationDataFacade; sourceTree = ""; }; - F397DE1C2CFF568800B72DA9 /* JSONs */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = JSONs; sourceTree = ""; }; - F3DEB38C2D47CBA200D0EFA4 /* InappSessionManagerTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = InappSessionManagerTests; sourceTree = ""; }; + F385631E2DB6729000D91208 /* InappConfigurationDataFacade */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = InappConfigurationDataFacade; + sourceTree = ""; + }; + F397DE1C2CFF568800B72DA9 /* JSONs */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = JSONs; + sourceTree = ""; + }; + F3DEB38C2D47CBA200D0EFA4 /* InappSessionManagerTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = InappSessionManagerTests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -2249,6 +2283,7 @@ 97FEDDEB5F71A67F1C4C675F /* MBNetworkFetcherResponseHandlingTests.swift */, F3BA5E000130A000C0000006 /* OperationsURLRoutingTests.swift */, F3CD202C2F600A800065392A /* HostNormalizerTests.swift */, + BECF3D292B29C1894F80948F /* MBEventRepositorySendRawTests.swift */, ); path = Network; sourceTree = ""; @@ -2694,6 +2729,7 @@ F3C1A0052F5B100100ABC001 /* InappShowFailureManagerTests.swift */, 2B4C84F6EDD4B7D977F67A95 /* WebView */, A1B2C3D400000007E1010101 /* Permissions */, + 4B7DAFAB687945FA908DB1AC /* TransparentViewSyncOperationResponseTests.swift */, ); path = Tests; sourceTree = ""; @@ -3501,25 +3537,6 @@ path = FeatureTogglesError; sourceTree = ""; }; - F3CD20282F600A800065392A /* Configuration */ = { - isa = PBXGroup; - children = ( - F3CD20272F600A800065392A /* MBConfigurationTests.swift */, - ); - path = Configuration; - sourceTree = ""; - }; - F3BA10502F500A800065392A /* BaseAddressesError */ = { - isa = PBXGroup; - children = ( - F3BA10512F500A800065392A /* SettingsBaseAddressesError.json */, - F3BA10522F500A800065392A /* SettingsBaseAddressesTypeError.json */, - F3BA10532F500A800065392A /* SettingsBaseAddressesOperationsError.json */, - F3BA10542F500A800065392A /* SettingsBaseAddressesOperationsTypeError.json */, - ); - path = BaseAddressesError; - sourceTree = ""; - }; F34A45AC2B76286D00634C8B /* PublicModels */ = { isa = PBXGroup; children = ( @@ -3732,6 +3749,17 @@ path = Config; sourceTree = ""; }; + F3BA10502F500A800065392A /* BaseAddressesError */ = { + isa = PBXGroup; + children = ( + F3BA10512F500A800065392A /* SettingsBaseAddressesError.json */, + F3BA10522F500A800065392A /* SettingsBaseAddressesTypeError.json */, + F3BA10532F500A800065392A /* SettingsBaseAddressesOperationsError.json */, + F3BA10542F500A800065392A /* SettingsBaseAddressesOperationsTypeError.json */, + ); + path = BaseAddressesError; + sourceTree = ""; + }; F3BD9F802F273BC800647BAF /* Bridge */ = { isa = PBXGroup; children = ( @@ -3742,6 +3770,14 @@ path = Bridge; sourceTree = ""; }; + F3CD20282F600A800065392A /* Configuration */ = { + isa = PBXGroup; + children = ( + F3CD20272F600A800065392A /* MBConfigurationTests.swift */, + ); + path = Configuration; + sourceTree = ""; + }; F3D818AE2A3885A40002957C /* ABTestDeviceMixer */ = { isa = PBXGroup; children = ( @@ -4745,6 +4781,8 @@ 1E3BD63AB3F1521C253CB818 /* MBNetworkFetcherResponseHandlingTests.swift in Sources */, F3BA5E000130A000C0000005 /* OperationsURLRoutingTests.swift in Sources */, F3CD202B2F600A800065392A /* HostNormalizerTests.swift in Sources */, + AF174B2121221D323FB95EF0 /* MBEventRepositorySendRawTests.swift in Sources */, + EA395B77BB16CEFE6DC91D1D /* TransparentViewSyncOperationResponseTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift index 1b1e63254..c82394899 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift @@ -386,32 +386,64 @@ extension TransparentView { Logger.common(message: "[WebView] syncOperation '\(params.name)' sending", level: .info, category: .webViewInAppMessages) - eventRepository.send(type: OperationResponse.self, event: event) { [weak self] result in + // HTTP 2xx → forward the raw body to JS as a Response so the JS Tracker + // can dispatch onSuccess / onValidationError by the body's `status`. + // 4xx, 5xx and network failures stay on the MindboxError → Error path. + eventRepository.sendRaw(event: event) { [weak self] result in DispatchQueue.main.async { - switch result { - case .success(let response): + let outgoing = TransparentView.makeSyncOperationResponse( + result: result, + action: message.action, + id: message.id + ) + switch outgoing.type { + case .response: Logger.common(message: "[WebView] syncOperation '\(params.name)' success", level: .info, category: .webViewInAppMessages) - let responseJSON = response.createJSON() - let successResponse = BridgeMessage( - type: .response, - action: message.action, - payload: .string(responseJSON), - id: message.id - ) - self?.facade?.sendToJS(successResponse) - - case .failure(let error): - Logger.common(message: "[WebView] syncOperation '\(params.name)' failed: \(error)", level: .error, category: .webViewInAppMessages) - let errorJSON = error.createJSON() - let errorResponse = BridgeMessage( - type: .error, - action: message.action, - payload: .string(errorJSON), - id: message.id - ) - self?.facade?.sendToJS(errorResponse) + case .error: + if case .failure(let error) = result { + Logger.common(message: "[WebView] syncOperation '\(params.name)' failed: \(error)", level: .error, category: .webViewInAppMessages) + } else { + Logger.common(message: "[WebView] syncOperation '\(params.name)' failed: non-UTF-8 response body", level: .error, category: .webViewInAppMessages) + } + default: + break } + self?.facade?.sendToJS(outgoing) + } + } + } + + /// Maps the raw `sendRaw` result of a `syncOperation` request to the outgoing + /// `BridgeMessage` sent back to JS. Pure function — no side effects — extracted + /// to keep the JS-bridge contract independently unit-testable. + static func makeSyncOperationResponse( + result: Result, + action: String, + id: UUID + ) -> BridgeMessage { + switch result { + case .success(let data): + guard let bodyString = String(data: data, encoding: .utf8) else { + return BridgeMessage( + type: .error, + action: action, + payload: .object(["error": .string("Response body is not valid UTF-8")]), + id: id + ) } + return BridgeMessage( + type: .response, + action: action, + payload: .string(bodyString), + id: id + ) + case .failure(let error): + return BridgeMessage( + type: .error, + action: action, + payload: .string(error.createJSON()), + id: id + ) } } } diff --git a/Mindbox/Network/Abstract/NetworkFetcher.swift b/Mindbox/Network/Abstract/NetworkFetcher.swift index 899ddc368..14d701252 100644 --- a/Mindbox/Network/Abstract/NetworkFetcher.swift +++ b/Mindbox/Network/Abstract/NetworkFetcher.swift @@ -22,6 +22,15 @@ protocol NetworkFetcher { completion: @escaping ((Result) -> Void) ) + /// Returns the raw HTTP 2xx response body without parsing `BaseResponse`, + /// so the caller can decide how to interpret it. 4xx, 5xx and network + /// failures still surface as `MindboxError` through the shared response + /// pipeline. + func requestRaw( + route: Route, + completion: @escaping ((Result) -> Void) + ) + /// Cancels all ongoing network tasks. func cancelAllTasks() } diff --git a/Mindbox/Network/MBNetworkFetcher.swift b/Mindbox/Network/MBNetworkFetcher.swift index 123bee386..4356118a5 100644 --- a/Mindbox/Network/MBNetworkFetcher.swift +++ b/Mindbox/Network/MBNetworkFetcher.swift @@ -133,6 +133,47 @@ class MBNetworkFetcher: NetworkFetcher { } } + func requestRaw( + route: Route, + completion: @escaping (Result) -> Void + ) { + guard let configuration = persistenceStorage.configuration else { + let error = MindboxError(.init( + errorKey: .invalidConfiguration, + reason: "Configuration is not set" + )) + Logger.error(error.asLoggerError()) + completion(.failure(error)) + return + } + + let builder = URLRequestBuilder( + domain: configuration.domain, + operationsDomain: resolvedOperationsDomain(configuration: configuration) + ) + do { + let urlRequest = try builder.asURLRequest(route: route) + Logger.network(request: urlRequest, httpAdditionalHeaders: session.configuration.httpAdditionalHeaders) + let startTime = CFAbsoluteTimeGetCurrent() + session.dataTask(with: urlRequest) { data, response, error in + let networkTimeMs = Int((CFAbsoluteTimeGetCurrent() - startTime) * 1000) + self.handleResponse(data, response, error, needBaseResponse: false, networkTimeMs: networkTimeMs) { result in + switch result { + case let .success(data): + completion(.success(data)) + case let .failure(error): + Logger.error(error.asLoggerError()) + completion(.failure(error)) + } + } + }.resume() + } catch let error { + let errorModel = MindboxError.unknown(error) + Logger.error(errorModel.asLoggerError()) + completion(.failure(errorModel)) + } + } + private func handleResponse( _ data: Data?, _ response: URLResponse?, diff --git a/Mindbox/NetworkRepository/Event/EventRepository.swift b/Mindbox/NetworkRepository/Event/EventRepository.swift index 5d68c7542..bddef28e7 100644 --- a/Mindbox/NetworkRepository/Event/EventRepository.swift +++ b/Mindbox/NetworkRepository/Event/EventRepository.swift @@ -12,7 +12,13 @@ import MindboxLogger protocol EventRepository { func send(event: Event, completion: @escaping (Result) -> Void) func send(type: T.Type, event: Event, completion: @escaping (Result) -> Void) where T: Decodable - + + /// Sends an event and returns the raw HTTP 2xx response body. Skips + /// `BaseResponse` parsing so the caller can dispatch on the body itself + /// (e.g. forward the bytes verbatim to the WebView JS bridge so the JS + /// Tracker can route by the body's `status` field). + func sendRaw(event: Event, completion: @escaping (Result) -> Void) + /// Cancels all ongoing network requests associated with this repository. func cancelAllRequests() } diff --git a/Mindbox/NetworkRepository/Event/MBEventRepository.swift b/Mindbox/NetworkRepository/Event/MBEventRepository.swift index 3beaee1a2..5efe99689 100644 --- a/Mindbox/NetworkRepository/Event/MBEventRepository.swift +++ b/Mindbox/NetworkRepository/Event/MBEventRepository.swift @@ -80,6 +80,41 @@ class MBEventRepository: EventRepository { }) } + func sendRaw(event: Event, completion: @escaping (Result) -> Void) { + guard let configuration = persistenceStorage.configuration else { + let error = MindboxError(.init( + errorKey: .invalidConfiguration, + reason: "Configuration is not set" + )) + completion(.failure(error)) + return + } + guard let deviceUUID = persistenceStorage.deviceUUID else { + let error = MindboxError(.init( + errorKey: .invalidConfiguration, + reason: "DeviceUUID is not set" + )) + completion(.failure(error)) + return + } + let wrapper = EventWrapper( + event: event, + endpoint: configuration.endpoint, + deviceUUID: deviceUUID + ) + let route = makeRoute(wrapper: wrapper) + fetcher.requestRaw(route: route) { result in + DispatchQueue.main.async { + switch result { + case let .failure(error): + completion(.failure(error)) + case let .success(data): + completion(.success(data)) + } + } + } + } + private func makeRoute(wrapper: EventWrapper) -> Route { switch wrapper.event.type { case .installed, diff --git a/MindboxTests/InApp/Tests/TransparentViewSyncOperationResponseTests.swift b/MindboxTests/InApp/Tests/TransparentViewSyncOperationResponseTests.swift new file mode 100644 index 000000000..bd245386e --- /dev/null +++ b/MindboxTests/InApp/Tests/TransparentViewSyncOperationResponseTests.swift @@ -0,0 +1,177 @@ +// +// TransparentViewSyncOperationResponseTests.swift +// MindboxTests +// + +import Testing +import Foundation +@_spi(Internal) @testable import Mindbox + +@Suite("TransparentView.makeSyncOperationResponse") +struct TransparentViewSyncOperationResponseTests { + + private let action = "syncOperation" + private let requestId = UUID() + + // MARK: - HTTP 200 + ValidationError body → .response with raw body (regression: MOBILE-164) + + @Test("HTTP 200 ValidationError body becomes .response with raw body string") + func validationErrorBody_becomesResponseWithRawBody() throws { + let rawBody = #"{"status":"ValidationError","validationMessages":[{"message":"Invalid email","location":"/customer/email"}]}"# + let data = try #require(rawBody.data(using: .utf8)) + + let outgoing = TransparentView.makeSyncOperationResponse( + result: .success(data), + action: action, + id: requestId + ) + + #expect(outgoing.type == .response) + #expect(outgoing.action == action) + #expect(outgoing.id == requestId) + if case .string(let value) = outgoing.payload { + #expect(value == rawBody, "Payload must be the raw body, not re-serialized") + } else { + Issue.record("Expected .string payload, got \(String(describing: outgoing.payload))") + } + } + + // MARK: - HTTP 200 + Success body → .response with raw body + + @Test("HTTP 200 Success body becomes .response with raw body string (not re-serialized)") + func successBody_becomesResponseWithRawBody() throws { + let rawBody = #"{"status":"Success","customer":{"email":"a@b.c"}}"# + let data = try #require(rawBody.data(using: .utf8)) + + let outgoing = TransparentView.makeSyncOperationResponse( + result: .success(data), + action: action, + id: requestId + ) + + #expect(outgoing.type == .response) + if case .string(let value) = outgoing.payload { + #expect(value == rawBody) + } else { + Issue.record("Expected .string payload, got \(String(describing: outgoing.payload))") + } + } + + // MARK: - HTTP 200 + non-JSON body → .response with raw body string + + @Test("HTTP 200 with non-JSON body still becomes .response (JS decides)") + func nonJSONBody_becomesResponseWithRawBody() throws { + let rawBody = "plain text body" + let data = try #require(rawBody.data(using: .utf8)) + + let outgoing = TransparentView.makeSyncOperationResponse( + result: .success(data), + action: action, + id: requestId + ) + + #expect(outgoing.type == .response) + if case .string(let value) = outgoing.payload { + #expect(value == rawBody) + } else { + Issue.record("Expected .string payload") + } + } + + // MARK: - HTTP 200 + empty body → .response with empty string + + @Test("HTTP 200 with empty body becomes .response with empty string payload") + func emptyBody_becomesResponseWithEmptyString() { + let outgoing = TransparentView.makeSyncOperationResponse( + result: .success(Data()), + action: action, + id: requestId + ) + + #expect(outgoing.type == .response) + if case .string(let value) = outgoing.payload { + #expect(value == "") + } else { + Issue.record("Expected .string payload") + } + } + + // MARK: - Non-UTF-8 body → .error with explanatory payload + + @Test("Non-UTF-8 body becomes .error with 'Response body is not valid UTF-8'") + func nonUTF8Body_becomesError() { + // Bytes that are not valid UTF-8: lone continuation byte 0xC3 + invalid follow-up + let data = Data([0xC3, 0x28]) + + let outgoing = TransparentView.makeSyncOperationResponse( + result: .success(data), + action: action, + id: requestId + ) + + #expect(outgoing.type == .error) + if case .object(let dict) = outgoing.payload, + case .string(let errorMessage) = dict["error"] { + #expect(errorMessage == "Response body is not valid UTF-8") + } else { + Issue.record("Expected .object payload with 'error' key, got \(String(describing: outgoing.payload))") + } + } + + // MARK: - Failure (.connectionError) → .error with createJSON payload + + @Test("Connection failure becomes .error with MindboxError.createJSON payload") + func connectionError_becomesError() { + let outgoing = TransparentView.makeSyncOperationResponse( + result: .failure(.connectionError), + action: action, + id: requestId + ) + + #expect(outgoing.type == .error) + if case .string(let value) = outgoing.payload { + #expect(value.contains("NetworkError"), "createJSON for connectionError produces a NetworkError envelope") + #expect(value.contains("Connection error")) + } else { + Issue.record("Expected .string payload") + } + } + + // MARK: - Failure (.protocolError) → .error with createJSON payload + + @Test("Protocol error becomes .error with MindboxError.createJSON payload") + func protocolError_becomesError() { + let pe = ProtocolError(status: .protocolError, errorMessage: "Bad", httpStatusCode: 400) + let outgoing = TransparentView.makeSyncOperationResponse( + result: .failure(.protocolError(pe)), + action: action, + id: requestId + ) + + #expect(outgoing.type == .error) + if case .string(let value) = outgoing.payload { + #expect(value.contains("MindboxError")) + #expect(value.contains("ProtocolError")) + } else { + Issue.record("Expected .string payload") + } + } + + // MARK: - id and action propagated + + @Test("Action and id from the request are preserved on the outgoing message") + func actionAndIdPreserved() throws { + let specificAction = "customAction" + let specificId = UUID() + let data = try #require("body".data(using: .utf8)) + + let outgoing = TransparentView.makeSyncOperationResponse( + result: .success(data), + action: specificAction, + id: specificId + ) + + #expect(outgoing.action == specificAction) + #expect(outgoing.id == specificId) + } +} diff --git a/MindboxTests/MindboxLogger/Mocks/EventRepositoryMock.swift b/MindboxTests/MindboxLogger/Mocks/EventRepositoryMock.swift index 08f8ee33e..a79f4f768 100644 --- a/MindboxTests/MindboxLogger/Mocks/EventRepositoryMock.swift +++ b/MindboxTests/MindboxLogger/Mocks/EventRepositoryMock.swift @@ -27,6 +27,10 @@ class EventRepositoryMock: EventRepository { func send(type: T.Type, event: Event, completion: @escaping (Result) -> Void) where T: Decodable { return } - + + func sendRaw(event: Event, completion: @escaping (Result) -> Void) { + return + } + func cancelAllRequests() {} } diff --git a/MindboxTests/Mock/MockFailureNetworkFetcher.swift b/MindboxTests/Mock/MockFailureNetworkFetcher.swift index 4de1b3717..66530b469 100644 --- a/MindboxTests/Mock/MockFailureNetworkFetcher.swift +++ b/MindboxTests/Mock/MockFailureNetworkFetcher.swift @@ -50,11 +50,23 @@ class MockFailureNetworkFetcher: NetworkFetcher { } } + func requestRaw(route: any Route, completion: @escaping (Result) -> Void) { + if !hasFailed { + hasFailed = true + completion(.failure(.internalError(.init( + errorKey: .parsing, + rawError: nil + )))) + } else { + completion(.success(MockFailureNetworkFetcher.successData)) + } + } + private static let successData: Data = { let bundle = Bundle(for: MockNetworkFetcher.self) let url = bundle.url(forResource: "SuccessResponse", withExtension: "json")! return try! Data(contentsOf: url) }() - + func cancelAllTasks() {} } diff --git a/MindboxTests/Mock/MockNetworkFetcher.swift b/MindboxTests/Mock/MockNetworkFetcher.swift index 95b7b1968..099b7a029 100644 --- a/MindboxTests/Mock/MockNetworkFetcher.swift +++ b/MindboxTests/Mock/MockNetworkFetcher.swift @@ -44,6 +44,14 @@ class MockNetworkFetcher: NetworkFetcher { } } } - + + func requestRaw(route: Route, completion: @escaping ((Result) -> Void)) { + if let error = error { + completion(.failure(error)) + } else { + completion(.success(data ?? Data())) + } + } + func cancelAllTasks() {} } diff --git a/MindboxTests/Network/MBEventRepositorySendRawTests.swift b/MindboxTests/Network/MBEventRepositorySendRawTests.swift new file mode 100644 index 000000000..667050606 --- /dev/null +++ b/MindboxTests/Network/MBEventRepositorySendRawTests.swift @@ -0,0 +1,211 @@ +// +// MBEventRepositorySendRawTests.swift +// MindboxTests +// + +import Testing +import Foundation +@testable import Mindbox + +@Suite("MBEventRepository.sendRaw") +struct MBEventRepositorySendRawTests { + + // MARK: - Test doubles + + private final class FakeNetworkFetcher: NetworkFetcher, @unchecked Sendable { + var requestRawResult: Result = .success(Data()) + private(set) var capturedRoute: Route? + private(set) var requestRawCallCount = 0 + + func request( + type: T.Type, + route: Route, + needBaseResponse: Bool, + completion: @escaping ((Result) -> Void) + ) where T: Decodable { + // not used by sendRaw + } + + func request(route: Route, completion: @escaping ((Result) -> Void)) { + // not used by sendRaw + } + + func requestRaw(route: Route, completion: @escaping ((Result) -> Void)) { + requestRawCallCount += 1 + capturedRoute = route + completion(requestRawResult) + } + + func cancelAllTasks() {} + } + + private func makeStorage(configured: Bool = true, deviceUUID: String? = "test-uuid") throws -> MockPersistenceStorage { + let storage = MockPersistenceStorage() + if configured { + storage.configuration = try MBConfiguration(endpoint: "test-endpoint", domain: "api.mindbox.ru") + } + storage.deviceUUID = deviceUUID + return storage + } + + private func makeSyncEvent(operation: String = "TestOp", body: String = "{}") -> Event { + let customEvent = CustomEvent(name: operation, payload: body) + return Event(type: .syncEvent, body: BodyEncoder(encodable: customEvent).body) + } + + private func awaitResult(_ work: (@escaping (Result) -> Void) -> Void) async -> Result { + await withCheckedContinuation { cont in + work { result in + cont.resume(returning: result) + } + } + } + + // MARK: - Success: forwards raw bytes from fetcher + + @Test("Success path forwards raw bytes from fetcher.requestRaw") + func sendRaw_success_forwardsRawBytes() async throws { + let fetcher = FakeNetworkFetcher() + let rawBody = #"{"status":"ValidationError","validationMessages":[{"message":"x","location":"/y"}]}"#.data(using: .utf8)! + fetcher.requestRawResult = .success(rawBody) + + let repo = MBEventRepository( + fetcher: fetcher, + persistenceStorage: try makeStorage() + ) + + let result = await awaitResult { completion in + repo.sendRaw(event: makeSyncEvent(), completion: completion) + } + + switch result { + case .success(let data): + #expect(data == rawBody) + case .failure(let error): + Issue.record("Expected success, got \(error)") + } + #expect(fetcher.requestRawCallCount == 1) + } + + // MARK: - Route: syncEvent uses EventRoute.syncEvent + + @Test("syncEvent event yields EventRoute.syncEvent") + func sendRaw_syncEvent_routesToSyncEndpoint() async throws { + let fetcher = FakeNetworkFetcher() + let repo = MBEventRepository( + fetcher: fetcher, + persistenceStorage: try makeStorage() + ) + + _ = await awaitResult { completion in + repo.sendRaw(event: makeSyncEvent(), completion: completion) + } + + let route = try #require(fetcher.capturedRoute as? EventRoute) + if case .syncEvent = route { + // expected + } else { + Issue.record("Expected EventRoute.syncEvent, got \(route)") + } + } + + // MARK: - Failure: fetcher error is passed through + + @Test("Fetcher failure is propagated") + func sendRaw_fetcherFailure_isPropagated() async throws { + let fetcher = FakeNetworkFetcher() + fetcher.requestRawResult = .failure(.connectionError) + + let repo = MBEventRepository( + fetcher: fetcher, + persistenceStorage: try makeStorage() + ) + + let result = await awaitResult { completion in + repo.sendRaw(event: makeSyncEvent(), completion: completion) + } + + switch result { + case .success: + Issue.record("Expected failure, got success") + case .failure(let error): + guard case .connectionError = error else { + Issue.record("Expected .connectionError, got \(error)") + return + } + } + } + + // MARK: - Missing configuration → invalidConfiguration error + + @Test("Missing configuration returns invalidConfiguration error") + func sendRaw_missingConfiguration_returnsInvalidConfiguration() async throws { + let fetcher = FakeNetworkFetcher() + let storage = try makeStorage(configured: false) + + let repo = MBEventRepository(fetcher: fetcher, persistenceStorage: storage) + + let result = await awaitResult { completion in + repo.sendRaw(event: makeSyncEvent(), completion: completion) + } + + switch result { + case .success: + Issue.record("Expected failure when configuration is nil") + case .failure(let error): + guard case .internalError(let ie) = error else { + Issue.record("Expected .internalError, got \(error)") + return + } + #expect(ie.errorKey == ErrorKey.invalidConfiguration.rawValue) + } + #expect(fetcher.requestRawCallCount == 0, "Fetcher must not be called when configuration is missing") + } + + // MARK: - Missing deviceUUID → invalidConfiguration error + + @Test("Missing deviceUUID returns invalidConfiguration error") + func sendRaw_missingDeviceUUID_returnsInvalidConfiguration() async throws { + let fetcher = FakeNetworkFetcher() + let storage = try makeStorage(deviceUUID: nil) + + let repo = MBEventRepository(fetcher: fetcher, persistenceStorage: storage) + + let result = await awaitResult { completion in + repo.sendRaw(event: makeSyncEvent(), completion: completion) + } + + switch result { + case .success: + Issue.record("Expected failure when deviceUUID is nil") + case .failure(let error): + guard case .internalError(let ie) = error else { + Issue.record("Expected .internalError, got \(error)") + return + } + #expect(ie.errorKey == ErrorKey.invalidConfiguration.rawValue) + } + #expect(fetcher.requestRawCallCount == 0, "Fetcher must not be called when deviceUUID is missing") + } + + // MARK: - Completion hops to main queue + + @Test("Completion is delivered on the main queue") + func sendRaw_completion_onMainQueue() async throws { + let fetcher = FakeNetworkFetcher() + fetcher.requestRawResult = .success(Data("ok".utf8)) + + let repo = MBEventRepository( + fetcher: fetcher, + persistenceStorage: try makeStorage() + ) + + let isMain: Bool = await withCheckedContinuation { cont in + repo.sendRaw(event: makeSyncEvent()) { _ in + cont.resume(returning: Thread.isMainThread) + } + } + + #expect(isMain) + } +} diff --git a/MindboxTests/Network/MBNetworkFetcherResponseHandlingTests.swift b/MindboxTests/Network/MBNetworkFetcherResponseHandlingTests.swift index d12e39da3..10c750257 100644 --- a/MindboxTests/Network/MBNetworkFetcherResponseHandlingTests.swift +++ b/MindboxTests/Network/MBNetworkFetcherResponseHandlingTests.swift @@ -639,6 +639,166 @@ final class MBNetworkFetcherResponseHandlingTests: XCTestCase { waitForExpectations(timeout: 1) } + // MARK: - requestRaw: HTTP 200 + ValidationError body → raw Data success (regression: MOBILE-164) + + func test_requestRaw_http200_statusValidationError_returnsRawData() throws { + let fetcher = try makeFetcher() + let body = validationErrorData() + stubResponse(statusCode: 200, body: body) + + let expectation = expectation(description: "completion") + fetcher.requestRaw(route: FetchInAppGeoRoute()) { result in + switch result { + case .success(let data): + XCTAssertEqual(data, body, "Raw body must reach caller byte-for-byte") + case .failure(let error): + XCTFail("HTTP 200 + ValidationError must NOT fail in requestRaw; got \(error)") + } + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - requestRaw: HTTP 200 + Success body → raw Data success + + func test_requestRaw_http200_statusSuccess_returnsRawData() throws { + let fetcher = try makeFetcher() + let body = baseResponseData(status: "Success") + stubResponse(statusCode: 200, body: body) + + let expectation = expectation(description: "completion") + fetcher.requestRaw(route: FetchInAppGeoRoute()) { result in + switch result { + case .success(let data): + XCTAssertEqual(data, body, "Raw body must NOT be re-serialized") + case .failure(let error): + XCTFail("Expected success, got \(error)") + } + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - requestRaw: HTTP 200 + arbitrary JSON without status → raw Data success + + func test_requestRaw_http200_arbitraryJSON_returnsRawData() throws { + let fetcher = try makeFetcher() + // No `status` field at all — BaseResponse would fail to parse, but requestRaw must not care. + let body = try XCTUnwrap(#"{"customer":{"email":"a@b.c"},"foo":42}"#.data(using: .utf8)) + stubResponse(statusCode: 200, body: body) + + let expectation = expectation(description: "completion") + fetcher.requestRaw(route: FetchInAppGeoRoute()) { result in + switch result { + case .success(let data): + XCTAssertEqual(data, body) + case .failure(let error): + XCTFail("Expected success, got \(error)") + } + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - requestRaw: HTTP 200 + non-JSON body → still raw Data success + + func test_requestRaw_http200_nonJSONBody_returnsRawData() throws { + let fetcher = try makeFetcher() + let body = try XCTUnwrap("hello world".data(using: .utf8)) + stubResponse(statusCode: 200, body: body) + + let expectation = expectation(description: "completion") + fetcher.requestRaw(route: FetchInAppGeoRoute()) { result in + switch result { + case .success(let data): + XCTAssertEqual(data, body) + case .failure(let error): + XCTFail("Expected success, got \(error)") + } + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - requestRaw: HTTP 403 → protocolError (unchanged from existing pipeline) + + func test_requestRaw_http403_returnsProtocolError() throws { + let fetcher = try makeFetcher() + let body = "Forbidden".data(using: .utf8) + stubResponse(statusCode: 403, body: body) + + let expectation = expectation(description: "completion") + fetcher.requestRaw(route: FetchInAppGeoRoute()) { result in + switch result { + case .success: + XCTFail("HTTP 403 must NOT be success") + case .failure(let error): + guard case .protocolError(let pe) = error else { + XCTFail("Expected protocolError, got \(error)") + expectation.fulfill() + return + } + XCTAssertEqual(pe.httpStatusCode, 403) + } + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - requestRaw: HTTP 500 → serverError (unchanged from existing pipeline) + + func test_requestRaw_http500_returnsServerError() throws { + let fetcher = try makeFetcher() + let body = protocolErrorData(status: "InternalServerError", message: "Boom", httpCode: 500) + stubResponse(statusCode: 500, body: body) + + let expectation = expectation(description: "completion") + fetcher.requestRaw(route: FetchInAppGeoRoute()) { result in + switch result { + case .success: + XCTFail("HTTP 500 must NOT be success") + case .failure(let error): + guard case .serverError(let pe) = error else { + XCTFail("Expected serverError, got \(error)") + expectation.fulfill() + return + } + XCTAssertEqual(pe.httpStatusCode, 500) + XCTAssertEqual(pe.errorMessage, "Boom") + } + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + // MARK: - requestRaw: missing configuration → invalidConfiguration + + func test_requestRaw_noConfiguration_returnsInvalidConfiguration() throws { + let persistenceStorage = MockPersistenceStorage() + // Intentionally leave persistenceStorage.configuration = nil + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [StubURLProtocol.self] + let session = URLSession(configuration: configuration) + let fetcher = MBNetworkFetcher(persistenceStorage: persistenceStorage, session: session) + + let expectation = expectation(description: "completion") + fetcher.requestRaw(route: FetchInAppGeoRoute()) { result in + switch result { + case .success: + XCTFail("Expected failure when configuration is nil") + case .failure(let error): + guard case .internalError(let ie) = error else { + XCTFail("Expected internalError, got \(error)") + expectation.fulfill() + return + } + XCTAssertEqual(ie.errorKey, ErrorKey.invalidConfiguration.rawValue) + } + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + // MARK: - Typed request + 200 + valid base response but invalid target type → parsing error func test_typedRequest_http200_invalidTargetType_returnsParsingError() throws { From 519c333a6f083b90fc50c4e70e185ad0dc92d6ed Mon Sep 17 00:00:00 2001 From: Anka Date: Thu, 21 May 2026 08:41:12 +0000 Subject: [PATCH 24/24] Bump SDK version from 2.15.0 to 2.15.1 --- 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 280da5308..2868119b0 100644 --- a/Mindbox.podspec +++ b/Mindbox.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = "Mindbox" - spec.version = "2.15.0" + spec.version = "2.15.1" 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.0' + spec.dependency 'MindboxLogger', '2.15.1' end diff --git a/MindboxLogger.podspec b/MindboxLogger.podspec index 7417e287e..593198c3a 100644 --- a/MindboxLogger.podspec +++ b/MindboxLogger.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = "MindboxLogger" - spec.version = "2.15.0" + spec.version = "2.15.1" 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 24376751a..a3e68cef2 100644 --- a/MindboxNotifications.podspec +++ b/MindboxNotifications.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = "MindboxNotifications" - spec.version = "2.15.0" + spec.version = "2.15.1" 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 5ae9e9f27..c491fb202 100644 --- a/SDKVersionProvider/SDKVersionConfig.xcconfig +++ b/SDKVersionProvider/SDKVersionConfig.xcconfig @@ -1 +1 @@ -MARKETING_VERSION = 2.15.0 +MARKETING_VERSION = 2.15.1 diff --git a/SDKVersionProvider/SDKVersionProvider.swift b/SDKVersionProvider/SDKVersionProvider.swift index 4931d20c8..618f478a1 100644 --- a/SDKVersionProvider/SDKVersionProvider.swift +++ b/SDKVersionProvider/SDKVersionProvider.swift @@ -8,6 +8,6 @@ import Foundation public class SDKVersionProvider { - public static let sdkVersion = "2.15.0" + public static let sdkVersion = "2.15.1" }