diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 6ce2fca25e6f..4a2a610f06f2 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -873,6 +873,7 @@ F0164EC32B4C49D30020268D /* ShadowsocksLoaderStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0164EC22B4C49D30020268D /* ShadowsocksLoaderStub.swift */; }; F0164ED12B4F2DCB0020268D /* AccessMethodIterator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0164ED02B4F2DCB0020268D /* AccessMethodIterator.swift */; }; F01DAE332C2B032A00521E46 /* RelaySelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = F01DAE322C2B032A00521E46 /* RelaySelection.swift */; }; + F022EBA62CF0C6AE009484B9 /* ConsolidatedApplicationLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5871FB95254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift */; }; F028A56A2A34D4E700C0CAA3 /* RedeemVoucherViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F028A5692A34D4E700C0CAA3 /* RedeemVoucherViewController.swift */; }; F028A56C2A34D8E600C0CAA3 /* AddCreditSucceededViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F028A56B2A34D8E600C0CAA3 /* AddCreditSucceededViewController.swift */; }; F02F41A02B9723AF00625A4F /* AddLocationsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02F419A2B9723AE00625A4F /* AddLocationsViewController.swift */; }; @@ -948,6 +949,8 @@ F09D04C12AF39EA2003D4F89 /* OutgoingConnectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09D04BC2AEBB7C5003D4F89 /* OutgoingConnectionService.swift */; }; F0A086902C22D6A700BF83E7 /* TunnelSettingsStrategyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A0868F2C22D6A700BF83E7 /* TunnelSettingsStrategyTests.swift */; }; F0A1638A2C47B77300592300 /* ServerRelaysResponse+Stubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ACE3342BE51745006D5333 /* ServerRelaysResponse+Stubs.swift */; }; + F0A7EBB22CEF6C79005BB671 /* ConsolidatedApplicationLogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A7EBB12CEF6C79005BB671 /* ConsolidatedApplicationLogTests.swift */; }; + F0A7EBB62CF092CC005BB671 /* ApplicationConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58BFA5CB22A7CE1F00A6173D /* ApplicationConfiguration.swift */; }; F0ACE30D2BE4E478006D5333 /* MullvadMockData.h in Headers */ = {isa = PBXBuildFile; fileRef = F0ACE30A2BE4E478006D5333 /* MullvadMockData.h */; settings = {ATTRIBUTES = (Public, ); }; }; F0ACE3102BE4E478006D5333 /* MullvadMockData.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F0ACE3082BE4E478006D5333 /* MullvadMockData.framework */; }; F0ACE3112BE4E478006D5333 /* MullvadMockData.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = F0ACE3082BE4E478006D5333 /* MullvadMockData.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -2170,6 +2173,7 @@ F09D04BF2AF39D63003D4F89 /* OutgoingConnectionServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutgoingConnectionServiceTests.swift; sourceTree = ""; }; F0A0868F2C22D6A700BF83E7 /* TunnelSettingsStrategyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelSettingsStrategyTests.swift; sourceTree = ""; }; F0A163882C47B46300592300 /* SingleHopEphemeralPeerExchangerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleHopEphemeralPeerExchangerTests.swift; sourceTree = ""; }; + F0A7EBB12CEF6C79005BB671 /* ConsolidatedApplicationLogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsolidatedApplicationLogTests.swift; sourceTree = ""; }; F0ACE3082BE4E478006D5333 /* MullvadMockData.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = MullvadMockData.framework; sourceTree = BUILT_PRODUCTS_DIR; }; F0ACE30A2BE4E478006D5333 /* MullvadMockData.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MullvadMockData.h; sourceTree = ""; }; F0ACE32E2BE4EA8B006D5333 /* MockProxyFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockProxyFactory.swift; sourceTree = ""; }; @@ -2429,6 +2433,7 @@ 440E9EF02BDA93CB00B1FD11 /* MullvadVPN */ = { isa = PBXGroup; children = ( + F0A7EBB02CEF6C5F005BB671 /* Log */, 440E9EF62BDA957300B1FD11 /* Classes */, 440E9F002BDA997C00B1FD11 /* Extensions */, 440E9EFB2BDA97C600B1FD11 /* GeneralAPIs */, @@ -4211,6 +4216,14 @@ path = GeneralAPIs; sourceTree = ""; }; + F0A7EBB02CEF6C5F005BB671 /* Log */ = { + isa = PBXGroup; + children = ( + F0A7EBB12CEF6C79005BB671 /* ConsolidatedApplicationLogTests.swift */, + ); + path = Log; + sourceTree = ""; + }; F0ACE3092BE4E478006D5333 /* MullvadMockData */ = { isa = PBXGroup; children = ( @@ -5388,6 +5401,7 @@ A9A5FA3B2ACB05910083449F /* UIMetrics.swift in Sources */, 58B07C182AEFDD6C00A09625 /* StoreTransactionLog.swift in Sources */, A9A5FA382ACB05600083449F /* InputTextFormatter.swift in Sources */, + F022EBA62CF0C6AE009484B9 /* ConsolidatedApplicationLog.swift in Sources */, A9A5FA372ACB052D0083449F /* ApplicationTarget.swift in Sources */, A9A5F9E12ACB05160083449F /* AddressCacheTracker.swift in Sources */, A9A5F9E22ACB05160083449F /* BackgroundTask.swift in Sources */, @@ -5498,6 +5512,7 @@ A9A5FA282ACB05160083449F /* WgKeyRotation.swift in Sources */, 449872E42B7CB96300094DDC /* TunnelSettingsUpdateTests.swift in Sources */, A9A5FA292ACB05160083449F /* AddressCacheTests.swift in Sources */, + F0A7EBB22CEF6C79005BB671 /* ConsolidatedApplicationLogTests.swift in Sources */, A9B6AC182ADE8F4300F7802A /* MigrationManagerTests.swift in Sources */, 7A9BE5AB2B909A1700E2A7D0 /* LocationDataSourceProtocol.swift in Sources */, A9A5FA2A2ACB05160083449F /* CoordinatesTests.swift in Sources */, @@ -5525,6 +5540,7 @@ A9A5FA342ACB05160083449F /* StringTests.swift in Sources */, 7A52F96C2C17450C00B133B9 /* RelaySelectorWrapperTests.swift in Sources */, A9A5FA352ACB05160083449F /* WgKeyRotationTests.swift in Sources */, + F0A7EBB62CF092CC005BB671 /* ApplicationConfiguration.swift in Sources */, 7AB4CCB92B69097E006037F5 /* IPOverrideTests.swift in Sources */, A9A5FA362ACB05160083449F /* TunnelManagerTests.swift in Sources */, ); diff --git a/ios/MullvadVPN/Classes/ConsolidatedApplicationLog.swift b/ios/MullvadVPN/Classes/ConsolidatedApplicationLog.swift index 48dbf6a72349..26faf3bb142e 100644 --- a/ios/MullvadVPN/Classes/ConsolidatedApplicationLog.swift +++ b/ios/MullvadVPN/Classes/ConsolidatedApplicationLog.swift @@ -15,6 +15,7 @@ private let kRedactedContainerPlaceholder = "[REDACTED CONTAINER PATH]" class ConsolidatedApplicationLog: TextOutputStreamable { typealias Metadata = KeyValuePairs + private let bufferSize: UInt64 = 131_072 enum MetadataKey: String { case id, os @@ -30,6 +31,10 @@ class ConsolidatedApplicationLog: TextOutputStreamable { let applicationGroupContainers: [URL] let metadata: Metadata + private let logQueue = DispatchQueue( + label: "com.mullvad.consolidation.logs.queue", + attributes: .concurrent + ) private var logs: [LogAttachment] = [] init( @@ -46,37 +51,50 @@ class ConsolidatedApplicationLog: TextOutputStreamable { } } - func addLogFiles(fileURLs: [URL]) { - for fileURL in fileURLs { - addSingleLogFile(fileURL) + func addLogFiles(fileURLs: [URL], completion: @escaping () -> Void = {}) { + logQueue.async(flags: .barrier) { + for fileURL in fileURLs { + self.addSingleLogFile(fileURL) + } + completion() } } - func addError(message: String, error: String) { + func addError(message: String, error: String, completion: (() -> Void)? = nil) { let redactedError = redact(string: error) - - logs.append(LogAttachment(label: message, content: redactedError)) + logQueue.async(flags: .barrier) { + self.logs.append(LogAttachment(label: message, content: redactedError)) + completion?() + } } var string: String { - var body = "" - write(to: &body) - return body + var result = "" + logQueue.sync { + var body = "" + self.write(to: &body) + result = body + } + return result } func write(to stream: inout some TextOutputStream) { - print("System information:", to: &stream) - for (key, value) in metadata { - print("\(key.rawValue): \(value)", to: &stream) - } - print("", to: &stream) - - for attachment in logs { - print(kLogDelimiter, to: &stream) - print(attachment.label, to: &stream) - print(kLogDelimiter, to: &stream) - print(attachment.content, to: &stream) - print("", to: &stream) + logQueue.sync { + var localOutput = "" + + localOutput += "System information:\n" + for (key, value) in metadata { + localOutput += "\(key.rawValue): \(value)\n" + } + localOutput += "\n" + for attachment in logs { + localOutput += "\(kLogDelimiter)\n" + localOutput += "\(attachment.label)\n" + localOutput += "\(kLogDelimiter)\n" + localOutput += "\(attachment.content)\n\n" + } + + stream.write(localOutput) } } @@ -92,10 +110,11 @@ class ConsolidatedApplicationLog: TextOutputStreamable { let path = fileURL.path let redactedPath = redact(string: path) - if let lossyString = Self.readFileLossy(path: path, maxBytes: ApplicationConfiguration.logMaximumFileSize) { + if let lossyString = readFileLossy(path: path, maxBytes: bufferSize) { let redactedString = redact(string: lossyString) - - logs.append(LogAttachment(label: redactedPath, content: redactedString)) + logQueue.async(flags: .barrier) { + self.logs.append(LogAttachment(label: redactedPath, content: redactedString)) + } } else { addError(message: redactedPath, error: "Log file does not exist: \(path).") } @@ -113,7 +132,7 @@ class ConsolidatedApplicationLog: TextOutputStreamable { ] } - private static func readFileLossy(path: String, maxBytes: UInt64) -> String? { + private func readFileLossy(path: String, maxBytes: UInt64) -> String? { guard let fileHandle = FileHandle(forReadingAtPath: path) else { return nil } @@ -125,12 +144,11 @@ class ConsolidatedApplicationLog: TextOutputStreamable { fileHandle.seek(toFileOffset: 0) } - let data = fileHandle.readData(ofLength: Int(ApplicationConfiguration.logMaximumFileSize)) + let data = fileHandle.readData(ofLength: Int(bufferSize)) let replacementCharacter = Character(UTF8.decode(UTF8.encodedReplacementCharacter)) let lossyString = String( String(decoding: data, as: UTF8.self) .drop { ch in - // Drop leading replacement characters produced when decoding data ch == replacementCharacter } ) @@ -147,9 +165,9 @@ class ConsolidatedApplicationLog: TextOutputStreamable { private func redact(string: String) -> String { [ redactContainerPaths, - Self.redactAccountNumber, - Self.redactIPv4Address, - Self.redactIPv6Address, + redactAccountNumber, + redactIPv4Address, + redactIPv6Address, redactCustomStrings, ].reduce(string) { resultString, transform -> String in transform(resultString) @@ -165,7 +183,7 @@ class ConsolidatedApplicationLog: TextOutputStreamable { } } - private static func redactAccountNumber(string: String) -> String { + private func redactAccountNumber(string: String) -> String { redact( // swiftlint:disable:next force_try regularExpression: try! NSRegularExpression(pattern: #"\d{16}"#), @@ -174,7 +192,7 @@ class ConsolidatedApplicationLog: TextOutputStreamable { ) } - private static func redactIPv4Address(string: String) -> String { + private func redactIPv4Address(string: String) -> String { redact( regularExpression: NSRegularExpression.ipv4RegularExpression, string: string, @@ -182,7 +200,7 @@ class ConsolidatedApplicationLog: TextOutputStreamable { ) } - private static func redactIPv6Address(string: String) -> String { + private func redactIPv6Address(string: String) -> String { redact( regularExpression: NSRegularExpression.ipv6RegularExpression, string: string, @@ -190,7 +208,7 @@ class ConsolidatedApplicationLog: TextOutputStreamable { ) } - private static func redact( + private func redact( regularExpression: NSRegularExpression, string: String, replacementString: String diff --git a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportInteractor.swift b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportInteractor.swift index bbc740f453ec..d0331ce06705 100644 --- a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportInteractor.swift +++ b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportInteractor.swift @@ -14,51 +14,73 @@ import Operations final class ProblemReportInteractor { private let apiProxy: APIQuerying private let tunnelManager: TunnelManager + private let consolidatedLog: ConsolidatedApplicationLog - private lazy var consolidatedLog: ConsolidatedApplicationLog = { - let securityGroupIdentifier = ApplicationConfiguration.securityGroupIdentifier - let redactStrings = [tunnelManager.deviceState.accountData?.number].compactMap { $0 } - - let report = ConsolidatedApplicationLog( - redactCustomStrings: redactStrings, - redactContainerPathsForSecurityGroupIdentifiers: [securityGroupIdentifier] + init(apiProxy: APIQuerying, tunnelManager: TunnelManager) { + self.apiProxy = apiProxy + self.tunnelManager = tunnelManager + self.consolidatedLog = ConsolidatedApplicationLog( + redactCustomStrings: [tunnelManager.deviceState.accountData?.number].compactMap { $0 }, + redactContainerPathsForSecurityGroupIdentifiers: [ApplicationConfiguration.securityGroupIdentifier] ) + } - let logFileURLs = ApplicationTarget.allCases.flatMap { - ApplicationConfiguration.logFileURLs(for: $0, in: ApplicationConfiguration.containerURL) - } - report.addLogFiles(fileURLs: logFileURLs) - - return report - }() - - var reportString: String { - consolidatedLog.string + func loadLogFiles(completion: @escaping () -> Void) { + DispatchQueue + .global() + .async { [weak self] in + guard let self else { return } + let logFileURLs = ApplicationTarget.allCases.flatMap { + ApplicationConfiguration.logFileURLs(for: $0, in: ApplicationConfiguration.containerURL) + } + consolidatedLog.addLogFiles(fileURLs: logFileURLs) { + DispatchQueue.main.async { + completion() + } + } + } } - init(apiProxy: APIQuerying, tunnelManager: TunnelManager) { - self.apiProxy = apiProxy - self.tunnelManager = tunnelManager + func fetchReportString(completion: @escaping (String) -> Void) { + DispatchQueue + .global() + .async { [weak self] in + guard let self else { return } + let result = self.consolidatedLog.string + DispatchQueue.main.async { + completion(result) + } + } } func sendReport( email: String, message: String, completion: @escaping (Result) -> Void - ) -> Cancellable { - let request = REST.ProblemReportRequest( - address: email, - message: message, - log: consolidatedLog.string, - metadata: consolidatedLog.metadata.reduce(into: [:]) { output, entry in - output[entry.key.rawValue] = entry.value - } - ) + ) { + DispatchQueue + .global() + .async { [weak self] in + guard let self else { return } + let logString = self.consolidatedLog.string + let metadataDict = self.consolidatedLog.metadata.reduce(into: [:]) { output, entry in + output[entry.key.rawValue] = entry.value + } + let request = REST.ProblemReportRequest( + address: email, + message: message, + log: logString, + metadata: metadataDict + ) - return apiProxy.sendProblemReport( - request, - retryStrategy: .default, - completionHandler: completion - ) + _ = self.apiProxy.sendProblemReport( + request, + retryStrategy: .default + ) { result in + DispatchQueue.main.async { + completion(result) + } + } + } } } diff --git a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportReviewViewController.swift b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportReviewViewController.swift index a12d32362a74..9a91f0971f04 100644 --- a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportReviewViewController.swift +++ b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportReviewViewController.swift @@ -9,8 +9,14 @@ import UIKit class ProblemReportReviewViewController: UIViewController { + private let spinnerView = SpinnerActivityIndicatorView(style: .large) private var textView = UITextView() private let interactor: ProblemReportInteractor + private lazy var spinnerContainerView: UIView = { + let view = UIView() + view.backgroundColor = .black.withAlphaComponent(0.5) + return view + }() init(interactor: ProblemReportInteractor) { self.interactor = interactor @@ -60,14 +66,20 @@ class ProblemReportReviewViewController: UIViewController { ) textView.backgroundColor = .systemBackground - view.addSubview(textView) + view.addConstrainedSubviews([textView]) { + textView.pinEdgesToSuperview() + } - NSLayoutConstraint.activate([ - textView.topAnchor.constraint(equalTo: view.topAnchor), - textView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - textView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - textView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) + textView.addConstrainedSubviews([spinnerContainerView]) { + spinnerContainerView.pinEdgesToSuperview() + spinnerContainerView.widthAnchor.constraint(equalTo: textView.widthAnchor) + spinnerContainerView.heightAnchor.constraint(equalTo: textView.heightAnchor) + } + + spinnerContainerView.addConstrainedSubviews([spinnerView]) { + spinnerView.centerXAnchor.constraint(equalTo: view.centerXAnchor) + spinnerView.centerYAnchor.constraint(equalTo: view.centerYAnchor) + } // Used to layout constraints so that navigation controller could properly adjust the text // view insets. @@ -81,30 +93,29 @@ class ProblemReportReviewViewController: UIViewController { } private func loadLogs() { - let presentation = AlertPresentation( - id: "problem-report-load", - icon: .spinner, - buttons: [] - ) - - let alertController = AlertViewController(presentation: presentation) - - present(alertController, animated: true) { - self.textView.text = self.interactor.reportString - self.dismiss(animated: true) + spinnerView.startAnimating() + self.interactor.loadLogFiles { [weak self] in + self?.interactor.fetchReportString { reportString in + self?.textView.text = reportString + self?.spinnerView.stopAnimating() + self?.spinnerContainerView.isHidden = true + } } } #if DEBUG private func share() { - let activityController = UIActivityViewController( - activityItems: [interactor.reportString], - applicationActivities: nil - ) + interactor.fetchReportString { [weak self] reportString in + guard let self,!reportString.isEmpty else { return } + let activityController = UIActivityViewController( + activityItems: [reportString], + applicationActivities: nil + ) - activityController.popoverPresentationController?.barButtonItem = navigationItem.leftBarButtonItem + activityController.popoverPresentationController?.barButtonItem = navigationItem.leftBarButtonItem - present(activityController, animated: true) + present(activityController, animated: true) + } } #endif } diff --git a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController.swift b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController.swift index 15d21ee9dbe1..e55caf939a10 100644 --- a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController.swift +++ b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController.swift @@ -258,7 +258,7 @@ final class ProblemReportViewController: UIViewController, UITextFieldDelegate { willSendProblemReport() - _ = interactor.sendReport( + interactor.sendReport( email: viewModel.email, message: viewModel.message ) { completion in diff --git a/ios/MullvadVPNTests/MullvadVPN/Log/ConsolidatedApplicationLogTests.swift b/ios/MullvadVPNTests/MullvadVPN/Log/ConsolidatedApplicationLogTests.swift new file mode 100644 index 000000000000..a9cc24449342 --- /dev/null +++ b/ios/MullvadVPNTests/MullvadVPN/Log/ConsolidatedApplicationLogTests.swift @@ -0,0 +1,186 @@ +// +// ConsolidatedApplicationLogTests.swift +// MullvadVPNTests +// +// Created by Mojgan on 2024-11-21. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import XCTest + +class ConsolidatedApplicationLogTests: XCTestCase { + var consolidatedLog: ConsolidatedApplicationLog! + let mockRedactStrings = ["sensitive", "secret"] + let mockSecurityGroupIdentifiers = ["group1", "group2"] + var createdMockFiles: [URL] = [] + let kRedactedPlaceholder = "[REDACTED]" + + override func setUp() { + super.setUp() + consolidatedLog = ConsolidatedApplicationLog( + redactCustomStrings: mockRedactStrings, + redactContainerPathsForSecurityGroupIdentifiers: mockSecurityGroupIdentifiers + ) + createdMockFiles = [] + } + + override func tearDownWithError() throws { + super.tearDown() + consolidatedLog = nil + // Remove all mock files created during tests + for file in createdMockFiles { + try FileManager.default.removeItem(at: file) + } + createdMockFiles = [] + } + + func testAddLogFiles() { + var string = "" + let expectation = self.expectation(description: "Log files added") + let mockFile = createMockFile(content: content, fileName: "\(generateRandomName()).txt") + + consolidatedLog.addLogFiles(fileURLs: [mockFile]) { + expectation.fulfill() + } + + waitForExpectations(timeout: 2) + self.consolidatedLog.write(to: &string) + XCTAssertTrue( + consolidatedLog.string.contains(string), + "Log should contain the file content." + ) + } + + func testAddError() { + let expectation = self.expectation(description: "Error added to log") + let errorMessage = "Test error" + let errorDetails = "A sensitive error occurred" + + consolidatedLog.addError(message: errorMessage, error: errorDetails) { + expectation.fulfill() + } + + waitForExpectations(timeout: 2) + XCTAssertTrue( + consolidatedLog.string.contains(errorMessage), + "Log should include the error message." + ) + } + + func testStringOutput() { + var string = "" + let mockFile = createMockFile(content: content, fileName: "\(generateRandomName()).txt") + consolidatedLog.addLogFiles(fileURLs: [mockFile]) + consolidatedLog.write(to: &string) + + let output = consolidatedLog.string + XCTAssertTrue(output.contains(string), "Output string should include redacted log content.") + } + + func testAddLogFilesRaceCondition() { + let count = 10 + var counter = 0 + let queue = DispatchQueue( + label: "com.mullvad.logs.addLogFilesConcurrentQueue", + attributes: .concurrent + ) + let expectation = self.expectation(description: "Race Condition in Add Log Files") + expectation.expectedFulfillmentCount = count + + for _ in 0 ..< count { + queue.async { + self.consolidatedLog.addLogFiles(fileURLs: [self.createMockFile( + content: self.content, + fileName: "\(self.generateRandomName()).txt" + )]) { + counter += 1 + expectation.fulfill() + } + } + } + + waitForExpectations(timeout: 3) + XCTAssertEqual(count, counter, "Counter mismatch detected. Possible race condition in adding log.") + } + + func testReportedStringRaceCondition() { + let count = 3 + var counter = 0 + var result = "" + + let queue = DispatchQueue( + label: "com.mullvad.logs.reportedStringConcurrentQueue", + attributes: .concurrent + ) + let serialQueue = DispatchQueue(label: "com.mullvad.logs.serialQueue") + let expectation = self.expectation(description: "Race Condition in read log") + expectation.expectedFulfillmentCount = count + + self.consolidatedLog.addLogFiles(fileURLs: [self.createMockFile( + content: self.content, + fileName: "\(self.generateRandomName()).txt" + )]) { + for _ in 0 ..< count { + queue.async { + var string = "" + self.consolidatedLog.write(to: &string) + serialQueue.async { + counter += 1 + result += string + expectation.fulfill() + } + } + } + } + + waitForExpectations(timeout: 3) + XCTAssertEqual(count, counter, "Counter mismatch detected. Possible race condition in reading log result.") + XCTAssertEqual(result, Array(repeating: consolidatedLog.string, count: count).joined()) + } + + // MARK: - Private functions + + private func createMockFile(content: String, fileName: String) -> URL { + let tempDirectory = FileManager.default.temporaryDirectory + let fileURL = tempDirectory.appendingPathComponent(fileName) + + do { + try content.write(to: fileURL, atomically: true, encoding: .utf8) + createdMockFiles.append(fileURL) + } catch { + XCTFail("Failed to create mock file: \(error)") + } + return fileURL + } + + private func generateRandomName() -> String { + let characterSet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + let randomName = (0 ..< 6).compactMap { _ in characterSet.randomElement() } + return String(randomName) + } +} + +extension ConsolidatedApplicationLogTests { + private var content: String { + return """ + MullvadVPN version xxxx.x + [22/11/2024 @ 08:52:22][AppDelegate][debug] Registered app refresh task. + [22/11/2024 @ 08:52:22][AppDelegate][debug] Registered address cache update task. + [22/11/2024 @ 08:52:22][AppDelegate][debug] Registered private key rotation task. + [22/11/2024 @ 08:52:23][TunnelManager][debug] Refresh device state + and tunnel status + due to application becoming active. + [22/11/2024 @ 08:52:23][RelayCacheTracker][debug] Start periodic relay updates. + [22/11/2024 @ 08:52:23][AddressCache.Tracker][debug] Start periodic address cache updates. + [22/11/2024 @ 08:52:23][AddressCache.Tracker][debug] Schedule address cache update at 23/11/2024 @ 08:49:52. + [22/11/2024 @ 08:52:23][AppDelegate][debug] Attempted migration from UI Process, but found nothing to do. + [22/11/2024 @ 08:52:23][TunnelManager][debug] Refresh tunnel status for new tunnel. + [22/11/2024 @ 08:52:23][REST.NetworkOperation][debug] name=get-access-token.2 + Send request + to /auth/v1/token via 127.0.0.1 using encrypted-dns-url-session. + [22/11/2024 @ 08:52:23][ApplicationRouter][debug] Presenting .main. + [22/11/2024 @ 08:52:23][REST.NetworkOperation][debug] name=get-access-token.2 Response: 200. + [22/11/2024 @ 08:52:23][AppDelegate][debug] Finished initialization. + """ + } +}