diff --git a/Modules/CommonsLib/Sources/CommonsLib/Constants.swift b/Modules/CommonsLib/Sources/CommonsLib/Constants.swift index e6ea5d41..d396b6bc 100644 --- a/Modules/CommonsLib/Sources/CommonsLib/Constants.swift +++ b/Modules/CommonsLib/Sources/CommonsLib/Constants.swift @@ -99,6 +99,7 @@ public struct Constants { public struct File { public static let LibDigidocLog = "libdigidocpp.log" + public static let AppLog = "ria_digidoc.log" public static let LDAPCertsPem = "ldapCerts.pem" public static let nfcCANKey = "canKey.txt" } diff --git a/Modules/UtilsLib/Sources/UtilsLib/Logging/LogCollector.swift b/Modules/UtilsLib/Sources/UtilsLib/Logging/LogCollector.swift new file mode 100644 index 00000000..0c90650e --- /dev/null +++ b/Modules/UtilsLib/Sources/UtilsLib/Logging/LogCollector.swift @@ -0,0 +1,161 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import CommonsLib +import Foundation +import OSLog + +public struct LogCollector: Sendable, Loggable { + // Move the log in small pieces so a large log never has to be held in memory all at once. + private static let chunkSize = 64 * 1024 + private static let logWindow: TimeInterval = 24 * 60 * 60 // 24 hours + + public init() {} + + @concurrent + public func writeLogFile( + to destination: URL, + libdigidocLog: URL?, + subsystemPrefix: String + ) async throws { + try await write(to: destination) { handle in + try append("===== File: \(CommonsLib.Constants.File.LibDigidocLog) =====\n\n", to: handle) + try copyContents(of: libdigidocLog, to: handle) + try append("\n\n===== File: \(CommonsLib.Constants.File.AppLog) =====\n\n", to: handle) + try appendLogEntries(matching: subsystemPrefix, to: handle) + } + } + + @concurrent + public func write(_ text: String, to destination: URL) async throws { + try Task.checkCancellation() + try text.write(to: destination, atomically: true, encoding: .utf8) + } + + private func write( + to destination: URL, + body: (FileHandle) throws -> Void + ) async throws { + try Task.checkCancellation() + + let fileManager = FileManager.default + let partial = destination.appendingPathExtension("partial") + try? fileManager.removeItem(at: partial) + + try Data().write(to: partial) + let handle = try FileHandle(forWritingTo: partial) + + do { + try body(handle) + try handle.close() + try Task.checkCancellation() + } catch { + try? handle.close() + try? fileManager.removeItem(at: partial) + throw error + } + + if fileManager.fileExists(atPath: destination.resolvedPath) { + _ = try fileManager.replaceItemAt(destination, withItemAt: partial) + } else { + try fileManager.moveItem(at: partial, to: destination) + } + } + + private func append(_ text: String, to handle: FileHandle) throws { + try handle.write(contentsOf: Data(text.utf8)) + } + + private func copyContents(of url: URL?, to handle: FileHandle) throws { + guard let url else { + LogCollector.logger().error("No libdigidocpp log location to copy from") + try append("\n", to: handle) + return + } + + let name = url.lastPathComponent + guard FileManager.default.fileExists(atPath: url.resolvedPath) else { + LogCollector.logger().info("No \(name, privacy: .public) to copy - not created this session") + try append("<\(name) was not created during this session>\n", to: handle) + return + } + + let reader: FileHandle + do { + reader = try FileHandle(forReadingFrom: url) + } catch { + let reason = String(reflecting: error) + LogCollector.logger().error("Unable to read \(name, privacy: .public): \(reason, privacy: .public)") + try append("\n", to: handle) + return + } + defer { try? reader.close() } + + var copiedBytes = 0 + while let chunk = try reader.read(upToCount: Self.chunkSize), !chunk.isEmpty { + try Task.checkCancellation() + try handle.write(contentsOf: chunk) + copiedBytes += chunk.count + } + + LogCollector.logger().info("Copied \(copiedBytes, privacy: .public) bytes from \(name, privacy: .public)") + } + + private func appendLogEntries(matching subsystemPrefix: String, to handle: FileHandle) throws { + let entries: AnySequence + do { + let store = try OSLogStore(scope: .currentProcessIdentifier) + let window = Date(timeIntervalSinceNow: -Self.logWindow) + let predicate = NSPredicate(format: "subsystem BEGINSWITH %@", subsystemPrefix) + entries = try store.getEntries(at: store.position(date: window), matching: predicate) + } catch { + let reason = String(reflecting: error) + LogCollector.logger().error("Unable to read the application log: \(reason, privacy: .public)") + try append("\n", to: handle) + return + } + + var batch = "" + var appendedEntries = 0 + for entry in entries { + try Task.checkCancellation() + batch.append(line(for: entry)) + batch.append("\n") + appendedEntries += 1 + + if batch.utf8.count >= Self.chunkSize { + try append(batch, to: handle) + batch.removeAll(keepingCapacity: true) + } + } + + if !batch.isEmpty { + try append(batch, to: handle) + } + + LogCollector.logger().info("Appended \(appendedEntries, privacy: .public) application log entries") + } + + private func line(for entry: OSLogEntry) -> String { + guard let log = entry as? OSLogEntryLog else { + return "\(entry.date): \(entry.composedMessage)" + } + return "\(entry.date) [\(log.subsystem):\(log.category)] - \(entry.composedMessage)" + } +} diff --git a/Modules/UtilsLib/Tests/UtilsLibTests/Logging/LogCollectorTests.swift b/Modules/UtilsLib/Tests/UtilsLibTests/Logging/LogCollectorTests.swift new file mode 100644 index 00000000..f4a3aafd --- /dev/null +++ b/Modules/UtilsLib/Tests/UtilsLibTests/Logging/LogCollectorTests.swift @@ -0,0 +1,271 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import CommonsLib +import CommonsTestShared +import Foundation +import OSLog +import Testing + +@testable import UtilsLib + +final class LogCollectorTests: Sendable { + + private let directory: URL + private let collector = LogCollector() + + init() throws { + directory = try TestFileUtil.getTemporaryDirectory( + subfolder: "LogCollectorTests-\(UUID().uuidString)" + ) + } + + deinit { + try? FileManager.default.removeItem(at: directory) + } + + @Test + func write_createsFileWithGivenText() async throws { + let destination = directory.appending(path: "diagnostics.log") + + try await collector.write("hello\nworld", to: destination) + + #expect(try String(contentsOf: destination, encoding: .utf8) == "hello\nworld") + } + + @Test + func write_replacesExistingFile() async throws { + let destination = directory.appending(path: "diagnostics.log") + try "stale".write(to: destination, atomically: true, encoding: .utf8) + + try await collector.write("fresh", to: destination) + + #expect(try String(contentsOf: destination, encoding: .utf8) == "fresh") + } + + @Test + func write_leavesNoPartialFileBehind() async throws { + let destination = directory.appending(path: "diagnostics.log") + + try await collector.write("done", to: destination) + + let partial = destination.appendingPathExtension("partial") + #expect(!FileManager.default.fileExists(atPath: partial.path)) + } + + @Test + func writeLogFile_includesBothSectionHeadersAndLibdigidocContents() async throws { + let libdigidocLog = directory.appending(path: Constants.File.LibDigidocLog) + try "libdigidoc line one\nlibdigidoc line two".write(to: libdigidocLog, atomically: true, encoding: .utf8) + let destination = directory.appending(path: "ria_digidoc.log") + + try await collector.writeLogFile( + to: destination, + libdigidocLog: libdigidocLog, + subsystemPrefix: "ee.ria.digidoc.LogCollectorTests" + ) + + let contents = try String(contentsOf: destination, encoding: .utf8) + #expect(contents.contains("===== File: \(Constants.File.LibDigidocLog) =====")) + #expect(contents.contains("===== File: \(Constants.File.AppLog) =====")) + #expect(contents.contains("libdigidoc line one")) + #expect(contents.contains("libdigidoc line two")) + } + + @Test + func writeLogFile_stillWritesHeadersWhenLibdigidocLogIsMissing() async throws { + let destination = directory.appending(path: "ria_digidoc.log") + + try await collector.writeLogFile( + to: destination, + libdigidocLog: directory.appending(path: "does-not-exist.log"), + subsystemPrefix: "ee.ria.digidoc.LogCollectorTests" + ) + + let contents = try String(contentsOf: destination, encoding: .utf8) + #expect(contents.contains("===== File: \(Constants.File.LibDigidocLog) =====")) + #expect(contents.contains("===== File: \(Constants.File.AppLog) =====")) + } + + @Test + func writeLogFile_leavesNoFileWhenCancelledBeforeCompletion() async throws { + let libdigidocLog = directory.appending(path: Constants.File.LibDigidocLog) + try String(repeating: "a line of libdigidoc output\n", count: 50) + .write(to: libdigidocLog, atomically: true, encoding: .utf8) + let destination = directory.appending(path: "ria_digidoc.log") + + let task = Task { + try await collector.writeLogFile( + to: destination, + libdigidocLog: libdigidocLog, + subsystemPrefix: "ee.ria.digidoc.LogCollectorTests" + ) + } + task.cancel() + + await #expect(throws: CancellationError.self) { try await task.value } + #expect(!FileManager.default.fileExists(atPath: destination.path)) + #expect(!FileManager.default.fileExists(atPath: destination.appendingPathExtension("partial").path)) + } + + @Test + func writeLogFile_keepsPreviousFileWhenCancelled() async throws { + let libdigidocLog = directory.appending(path: Constants.File.LibDigidocLog) + try String(repeating: "a line of libdigidoc output\n", count: 50) + .write(to: libdigidocLog, atomically: true, encoding: .utf8) + let destination = directory.appending(path: "ria_digidoc.log") + try "previous log".write(to: destination, atomically: true, encoding: .utf8) + + let task = Task { + try await collector.writeLogFile( + to: destination, + libdigidocLog: libdigidocLog, + subsystemPrefix: "ee.ria.digidoc.LogCollectorTests" + ) + } + task.cancel() + + await #expect(throws: CancellationError.self) { try await task.value } + #expect(try String(contentsOf: destination, encoding: .utf8) == "previous log") + } + + @Test + func writeLogFile_throwsWhenDestinationDirectoryDoesNotExist() async throws { + let destination = directory.appending(path: "missing").appending(path: "ria_digidoc.log") + + await #expect(throws: (any Error).self) { + try await collector.writeLogFile( + to: destination, + libdigidocLog: nil, + subsystemPrefix: "ee.ria.digidoc.LogCollectorTests" + ) + } + } + + @Test + func writeLogFile_includesMatchingOSLogEntries() async throws { + let subsystem = "ee.ria.digidoc.LogCollectorTests.\(UUID().uuidString.prefix(8))" + let marker = "collector-marker-\(UUID().uuidString)" + Logger(subsystem: subsystem, category: "OSLogPath").info("\(marker, privacy: .public)") + + let destination = directory.appending(path: "ria_digidoc.log") + var contents = "" + + for attempt in 0..<3 where !contents.contains(marker) { + if attempt > 0 { try await Task.sleep(for: .milliseconds(200)) } + try await collector.writeLogFile(to: destination, libdigidocLog: nil, subsystemPrefix: subsystem) + contents = try String(contentsOf: destination, encoding: .utf8) + } + + #expect(contents.contains(marker)) + #expect(contents.contains("[\(subsystem):OSLogPath]")) + } + + @Test + func writeLogFile_excludesEntriesFromOtherSubsystems() async throws { + let marker = "excluded-marker-\(UUID().uuidString)" + Logger(subsystem: "ee.ria.digidoc.SomeOtherSubsystem", category: "Other") + .info("\(marker, privacy: .public)") + + let destination = directory.appending(path: "ria_digidoc.log") + try await collector.writeLogFile( + to: destination, + libdigidocLog: nil, + subsystemPrefix: "ee.ria.digidoc.LogCollectorTests.NoSuchSubsystem" + ) + + #expect(try !String(contentsOf: destination, encoding: .utf8).contains(marker)) + } + + @Test + func writeLogFile_notesWhenLibdigidocLogWasNeverCreated() async throws { + let destination = directory.appending(path: "ria_digidoc.log") + + try await collector.writeLogFile( + to: destination, + libdigidocLog: directory.appending(path: Constants.File.LibDigidocLog), + subsystemPrefix: "ee.ria.digidoc.LogCollectorTests.NoSuchSubsystem" + ) + + let contents = try String(contentsOf: destination, encoding: .utf8) + #expect(contents.contains("was not created during this session")) + } + + @Test + func writeLogFile_notesWhenLibdigidocLogCannotBeRead() async throws { + let libdigidocLog = directory.appending(path: Constants.File.LibDigidocLog) + try "unreadable content".write(to: libdigidocLog, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0], ofItemAtPath: libdigidocLog.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o644], + ofItemAtPath: libdigidocLog.path) } + + let destination = directory.appending(path: "ria_digidoc.log") + try await collector.writeLogFile( + to: destination, + libdigidocLog: libdigidocLog, + subsystemPrefix: "ee.ria.digidoc.LogCollectorTests.NoSuchSubsystem" + ) + + let contents = try String(contentsOf: destination, encoding: .utf8) + #expect(contents.contains("unable to read \(Constants.File.LibDigidocLog)")) + #expect(!contents.contains("unreadable content")) + } + + @Test + func writeLogFile_notesWhenLibdigidocLogLocationIsUnknown() async throws { + let destination = directory.appending(path: "ria_digidoc.log") + + try await collector.writeLogFile( + to: destination, + libdigidocLog: nil, + subsystemPrefix: "ee.ria.digidoc.LogCollectorTests.NoSuchSubsystem" + ) + + let contents = try String(contentsOf: destination, encoding: .utf8) + #expect(contents.contains("could not determine the libdigidocpp log location")) + } + + @Test + func write_throwsWhenAlreadyCancelled() async throws { + let destination = directory.appending(path: "diagnostics.log") + + let task = Task { try await collector.write("some text", to: destination) } + task.cancel() + + await #expect(throws: CancellationError.self) { try await task.value } + #expect(!FileManager.default.fileExists(atPath: destination.path)) + } + + @Test + func writeLogFile_throwsWhenAlreadyCancelledWithNothingToCopy() async throws { + let destination = directory.appending(path: "ria_digidoc.log") + + let task = Task { + try await collector.writeLogFile( + to: destination, + libdigidocLog: nil, + subsystemPrefix: "ee.ria.digidoc.LogCollectorTests.NoSuchSubsystem" + ) + } + task.cancel() + + await #expect(throws: CancellationError.self) { try await task.value } + #expect(!FileManager.default.fileExists(atPath: destination.path)) + } +} diff --git a/RIADigiDoc/Supporting files/Localizable.xcstrings b/RIADigiDoc/Supporting files/Localizable.xcstrings index 25ba3fcc..a69c78f4 100644 --- a/RIADigiDoc/Supporting files/Localizable.xcstrings +++ b/RIADigiDoc/Supporting files/Localizable.xcstrings @@ -1241,6 +1241,24 @@ } } }, + "Container validity cannot be extended" : { + "comment" : "Dialog title when a BDOC/DDOC could not be extended and was wrapped into a timestamped ASiC-S", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Container validity cannot be extended" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konteineri kehtivust ei saa pikendada" + } + } + } + }, "Continue" : { "comment" : "My eID PIN change or unblock view step button", "extractionState" : "manual", @@ -1799,6 +1817,42 @@ } } }, + "Decrypt general error" : { + "comment" : "CryptoContainer password decrypt error message", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Container decryption was unsuccessful" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ümbriku dekrüpteerimine ebaõnnestus" + } + } + } + }, + "Decrypt wrong password error" : { + "comment" : "CryptoContainer password decrypt — wrong password error", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wrong password" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vale parool" + } + } + } + }, "Decrypting in progress" : { "comment" : "NFC decrypt alert message step 4", "extractionState" : "manual", @@ -2069,42 +2123,6 @@ } } }, - "Decrypt general error" : { - "comment" : "CryptoContainer password decrypt error message", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Container decryption was unsuccessful" - } - }, - "et" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ümbriku dekrüpteerimine ebaõnnestus" - } - } - } - }, - "Decrypt wrong password error" : { - "comment" : "CryptoContainer password decrypt — wrong password error", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wrong password" - } - }, - "et" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vale parool" - } - } - } - }, "Encrypt general error" : { "comment" : "CryptoContainer encrypt error message", "extractionState" : "manual", @@ -2321,74 +2339,56 @@ } } }, - "Container validity cannot be extended" : { - "comment" : "Dialog title when a BDOC/DDOC could not be extended and was wrapped into a timestamped ASiC-S", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Container validity cannot be extended" - } - }, - "et" : { - "stringUnit" : { - "state" : "translated", - "value" : "Konteineri kehtivust ei saa pikendada" - } - } - } - }, - "Failed to extend signatures and wrapped to timestamped container." : { - "comment" : "Dialog message when a BDOC/DDOC could not be extended and was wrapped into a timestamped ASiC-S", + "Failed mobile-ID transaction" : { + "comment" : "Mobile-ID error for signatureHashMismatch", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Failed to extend signatures and wrapped to timestamped container." + "value" : "Your mobile-ID transaction has failed. Please contact your mobile network operator" } }, "et" : { "stringUnit" : { "state" : "translated", - "value" : "Allkirjade pikendamine ebaõnnestus ja konteiner pakiti ajatempliga konteinerisse." + "value" : "Sinu mobiil-ID toiming ebaõnnestus. Palun võta ühendust enda mobiilioperaatoriga" } } } }, - "Failed mobile-ID transaction" : { - "comment" : "Mobile-ID error for signatureHashMismatch", + "Failed Smart-ID transaction" : { + "comment" : "Smart-ID error", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Your mobile-ID transaction has failed. Please contact your mobile network operator" + "value" : "Your Smart-ID transaction has failed. Please check your Smart-ID application or contact Smart-ID customer support" } }, "et" : { "stringUnit" : { "state" : "translated", - "value" : "Sinu mobiil-ID toiming ebaõnnestus. Palun võta ühendust enda mobiilioperaatoriga" + "value" : "Sinu Smart-ID toiming ebaõnnestus. Palun kontrolli Smart-ID rakendust või võta ühendust Smart-ID klienditoega" } } } }, - "Failed Smart-ID transaction" : { - "comment" : "Smart-ID error", + "Failed to extend signatures and wrapped to timestamped container." : { + "comment" : "Dialog message when a BDOC/DDOC could not be extended and was wrapped into a timestamped ASiC-S", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Your Smart-ID transaction has failed. Please check your Smart-ID application or contact Smart-ID customer support" + "value" : "Failed to extend signatures and wrapped to timestamped container." } }, "et" : { "stringUnit" : { "state" : "translated", - "value" : "Sinu Smart-ID toiming ebaõnnestus. Palun kontrolli Smart-ID rakendust või võta ühendust Smart-ID klienditoega" + "value" : "Allkirjade pikendamine ebaõnnestus ja konteiner pakiti ajatempliga konteinerisse." } } } @@ -2573,6 +2573,24 @@ } } }, + "Failed to save file message" : { + "comment" : "Error message when unable to save file, without naming the file", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to save file" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Faili salvestamine ebaõnnestus" + } + } + } + }, "File" : { "comment" : "Used in data file view for accessibility", "extractionState" : "manual", @@ -2789,6 +2807,96 @@ } } }, + "ID card courier activate button" : { + "comment" : "Courier (unactivated) ID-card activation link text", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activate ID-card" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiveeri ID-kaart" + } + } + } + }, + "ID card courier activate URL" : { + "comment" : "Courier (unactivated) ID-card activation URL", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "https://www.politsei.ee/en/self-service-portal/" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "https://www.politsei.ee/et/iseteenindus/" + } + } + } + }, + "ID card courier must activate to decrypt" : { + "comment" : "Courier (unactivated) ID-card message shown during decryption", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The ID-card must be activated in order to decrypt." + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dekrüpteerimiseks tuleb ID-kaart aktiveerida." + } + } + } + }, + "ID card courier must activate to sign" : { + "comment" : "Courier (unactivated) ID-card message shown during signing", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The ID-card must be activated in order to sign." + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allkirjastamiseks tuleb ID-kaart aktiveerida." + } + } + } + }, + "ID card courier warning message" : { + "comment" : "Courier (unactivated) ID-card warning shown in My eID", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Authentication and signing with the ID-card isn't possible yet. ID-card must be activated in the Police and Border Guard Board's self-service portal in order to use it." + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "ID-kaardiga isikutuvastamine ja allkirjastamine ei ole veel võimalik. ID-kaardi kasutamiseks tuleb see aktiveerida Politsei- ja Piirivalveameti iseteeninduses." + } + } + } + }, "ID card detected" : { "comment" : "ID-card view card detected message", "extractionState" : "manual", @@ -3354,6 +3462,24 @@ } } }, + "Lock type" : { + "comment" : "Recipient detail view — lock type label for password recipients", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lock type" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Luku tüüp" + } + } + } + }, "Main about 1 logo text" : { "comment" : "Main about Estonia logo text", "extractionState" : "manual", @@ -3966,24 +4092,6 @@ } } }, - "Main diagnostics settings title" : { - "comment" : "Main diagnostics settings title", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Settings:" - } - }, - "et" : { - "stringUnit" : { - "state" : "translated", - "value" : "Settings:" - } - } - } - }, "Main diagnostics central configuration title" : { "comment" : "Main diagnostics central configuration title", "extractionState" : "manual", @@ -4272,6 +4380,24 @@ } } }, + "Main diagnostics settings title" : { + "comment" : "Main diagnostics settings title", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings:" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings:" + } + } + } + }, "Main diagnostics title" : { "comment" : "Main diagnostics title", "extractionState" : "manual", @@ -7110,24 +7236,6 @@ } } }, - "Lock type" : { - "comment" : "Recipient detail view — lock type label for password recipients", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lock type" - } - }, - "et" : { - "stringUnit" : { - "state" : "translated", - "value" : "Luku tüüp" - } - } - } - }, "Recipient" : { "comment" : "Home title, signer details title, accessibility recipient prefix", "extractionState" : "manual", @@ -9279,96 +9387,6 @@ } } } - }, - "ID card courier activate URL" : { - "comment" : "Courier (unactivated) ID-card activation URL", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "https://www.politsei.ee/en/self-service-portal/" - } - }, - "et" : { - "stringUnit" : { - "state" : "translated", - "value" : "https://www.politsei.ee/et/iseteenindus/" - } - } - } - }, - "ID card courier activate button" : { - "comment" : "Courier (unactivated) ID-card activation link text", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Activate ID-card" - } - }, - "et" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aktiveeri ID-kaart" - } - } - } - }, - "ID card courier must activate to decrypt" : { - "comment" : "Courier (unactivated) ID-card message shown during decryption", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The ID-card must be activated in order to decrypt." - } - }, - "et" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dekrüpteerimiseks tuleb ID-kaart aktiveerida." - } - } - } - }, - "ID card courier must activate to sign" : { - "comment" : "Courier (unactivated) ID-card message shown during signing", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The ID-card must be activated in order to sign." - } - }, - "et" : { - "stringUnit" : { - "state" : "translated", - "value" : "Allkirjastamiseks tuleb ID-kaart aktiveerida." - } - } - } - }, - "ID card courier warning message" : { - "comment" : "Courier (unactivated) ID-card warning shown in My eID", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Authentication and signing with the ID-card isn't possible yet. ID-card must be activated in the Police and Border Guard Board's self-service portal in order to use it." - } - }, - "et" : { - "stringUnit" : { - "state" : "translated", - "value" : "ID-kaardiga isikutuvastamine ja allkirjastamine ei ole veel võimalik. ID-kaardi kasutamiseks tuleb see aktiveerida Politsei- ja Piirivalveameti iseteeninduses." - } - } - } } }, "version" : "1.1" diff --git a/RIADigiDoc/UI/Component/DiagnosticsView.swift b/RIADigiDoc/UI/Component/DiagnosticsView.swift index 3d0692aa..85faf89a 100644 --- a/RIADigiDoc/UI/Component/DiagnosticsView.swift +++ b/RIADigiDoc/UI/Component/DiagnosticsView.swift @@ -26,7 +26,6 @@ struct DiagnosticsView: View { @AppTypography private var typography @Environment(LanguageSettings.self) private var languageSettings - @Environment(\.accessibilityVoiceOverEnabled) private var voiceOverEnabled @Environment(\.dismiss) private var dismiss @Environment(\.openURL) var openURL @@ -38,6 +37,8 @@ struct DiagnosticsView: View { } @State private var activeExportType: ExportType? + @State private var exportTask: Task? + @State private var generatingExportType: ExportType? @State private var tempFileURL: URL? @State private var isShowingFileSaver: Bool = false @State private var isFileSaved: Bool = false @@ -71,28 +72,31 @@ struct DiagnosticsView: View { DiagnosticsHeaderButtons( onCheckUpdateClick: onCheckUpdateClick, onSaveDiagnosticsClick: { - Task { - tempFileURL = await viewModel.createDiagnosticsFile( + startExport(type: .diagnosticsFile) { + await viewModel.createDiagnosticsFile( languageSettings: languageSettings ) - triggerFileSaver(type: .diagnosticsFile) } - } + }, + isSavingDiagnostics: generatingExportType == .diagnosticsFile, + isSaveDiagnosticsEnabled: generatingExportType != .logFile ) ToggleSection( isOn: $viewModel.enableOneTimeLogGeneration, label: languageSettings.localized("Main diagnostics logging switch") ) + .disabled(generatingExportType != nil) if viewModel.showSaveLogButton { PrimaryOutlinedButton( text: languageSettings.localized("Main diagnostics save log"), assetImageName: "ic_m3_download_48pt_wght400", + isButtonEnabled: generatingExportType != .diagnosticsFile, + isLoading: generatingExportType == .logFile, action: { - Task { - tempFileURL = await viewModel.createLogFile() - triggerFileSaver(type: .logFile) + startExport(type: .logFile) { + await viewModel.createLogFile() } }, focusedField: nil, @@ -134,7 +138,8 @@ struct DiagnosticsView: View { await handleFileSaverCompletion() } }, - isFileSaved: $isFileSaved + isFileSaved: $isFileSaved, + showsFileNameOnFailure: false ) ) .alert( @@ -164,6 +169,7 @@ struct DiagnosticsView: View { } } .onDisappear { + exportTask?.cancel() Task { await viewModel.removeObservers() } @@ -185,11 +191,7 @@ struct DiagnosticsView: View { Toast.show(updateMessage, type: isUpdated ? .success : .error) - if voiceOverEnabled { - var saveButtonAccessibilityAnnouncement = AttributedString(updateMessage) - saveButtonAccessibilityAnnouncement.accessibilitySpeechAnnouncementPriority = .high - AccessibilityNotification.Announcement(saveButtonAccessibilityAnnouncement).post() - } + AccessibilityUtil.announceMessage(updateMessage) } } @@ -205,24 +207,50 @@ struct DiagnosticsView: View { } } - private func triggerFileSaver(type: ExportType) { - self.activeExportType = type - if fileUtil.fileExists(fileLocation: tempFileURL) { - isShowingFileSaver = true + private func startExport(type: ExportType, generate: @escaping () async -> URL?) { + guard generatingExportType == nil else { return } + + generatingExportType = type + AccessibilityUtil.announceMessage(languageSettings.localized("Loading")) + + exportTask = Task { + let fileURL = await generate() + + generatingExportType = nil + guard !Task.isCancelled else { return } + + triggerFileSaver(type: type, fileURL: fileURL) } } + private func triggerFileSaver(type: ExportType, fileURL: URL?) { + guard fileUtil.fileExists(fileLocation: fileURL) else { + showExportFailure() + return + } + + tempFileURL = fileURL + activeExportType = type + isShowingFileSaver = true + } + + private func showExportFailure() { + let message = languageSettings.localized("Failed to save file message") + Toast.show(message, type: .error) + AccessibilityUtil.announceMessage(message) + } + private func handleFileSaverCompletion() async { guard let type = activeExportType else { return } + activeExportType = nil switch type { case .diagnosticsFile: viewModel.onDiagnosticsFileSavingComplete() case .logFile: + guard isFileSaved else { return } await viewModel.onLogFileSavingComplete() } - - activeExportType = nil } } diff --git a/RIADigiDoc/UI/Component/DiagnosticsView/DiagnosticsHeaderButtons.swift b/RIADigiDoc/UI/Component/DiagnosticsView/DiagnosticsHeaderButtons.swift index cc83b5d7..347fb944 100644 --- a/RIADigiDoc/UI/Component/DiagnosticsView/DiagnosticsHeaderButtons.swift +++ b/RIADigiDoc/UI/Component/DiagnosticsView/DiagnosticsHeaderButtons.swift @@ -25,6 +25,8 @@ struct DiagnosticsHeaderButtons: View { let onCheckUpdateClick: () -> Void let onSaveDiagnosticsClick: () -> Void + var isSavingDiagnostics: Bool = false + var isSaveDiagnosticsEnabled: Bool = true var body: some View { VStack(spacing: Dimensions.Padding.XSPadding) { @@ -40,6 +42,8 @@ struct DiagnosticsHeaderButtons: View { text: languageSettings.localized( "Main diagnostics configuration save diagnostics button"), assetImageName: "ic_m3_download_48pt_wght400", + isButtonEnabled: isSaveDiagnosticsEnabled, + isLoading: isSavingDiagnostics, action: onSaveDiagnosticsClick, focusedField: nil, currentFocus: .constant(nil) diff --git a/RIADigiDoc/UI/Component/DiagnosticsView/PrimaryOutlinedButton.swift b/RIADigiDoc/UI/Component/DiagnosticsView/PrimaryOutlinedButton.swift index 4e16d98d..066fd24f 100644 --- a/RIADigiDoc/UI/Component/DiagnosticsView/PrimaryOutlinedButton.swift +++ b/RIADigiDoc/UI/Component/DiagnosticsView/PrimaryOutlinedButton.swift @@ -27,6 +27,7 @@ struct PrimaryOutlinedButton: View { private let text: String private let assetImageName: String? private let isButtonEnabled: Bool + private let isLoading: Bool private let action: () -> Void @Binding private var currentFocus: AccessibilityField? @@ -38,6 +39,7 @@ struct PrimaryOutlinedButton: View { text: String, assetImageName: String?, isButtonEnabled: Bool = true, + isLoading: Bool = false, action: @escaping () -> Void, focusedField: AccessibilityField?, currentFocus: Binding, @@ -45,6 +47,7 @@ struct PrimaryOutlinedButton: View { self.text = text self.assetImageName = assetImageName self.isButtonEnabled = isButtonEnabled + self.isLoading = isLoading self.action = action self.focusedField = focusedField self._currentFocus = currentFocus @@ -55,7 +58,13 @@ struct PrimaryOutlinedButton: View { action: action, label: { HStack { - if let image = assetImageName { + if isLoading { + ProgressView() + .progressViewStyle(.circular) + .tint(theme.primary) + .frame(width: Dimensions.Icon.IconSizeXXS, height: Dimensions.Icon.IconSizeXXS) + .accessibilityHidden(true) + } else if let image = assetImageName { Image(image) .resizable() .scaledToFit() @@ -81,7 +90,7 @@ struct PrimaryOutlinedButton: View { .stroke(theme.outline, lineWidth: Dimensions.Height.XSBorder) ) }) - .disabled(!isButtonEnabled) + .disabled(!isButtonEnabled || isLoading) .accessibilityFocused($isFocused) .onAppear { Task { diff --git a/RIADigiDoc/UI/Component/Shared/FileSaverHandler.swift b/RIADigiDoc/UI/Component/Shared/FileSaverHandler.swift index 1e4c671d..d9147321 100644 --- a/RIADigiDoc/UI/Component/Shared/FileSaverHandler.swift +++ b/RIADigiDoc/UI/Component/Shared/FileSaverHandler.swift @@ -25,6 +25,7 @@ struct FileSaverHandler: View { let languageSettings: LanguageSettings let onComplete: (() -> Void)? @Binding var isFileSaved: Bool + var showsFileNameOnFailure: Bool = true var body: some View { Group { @@ -54,7 +55,9 @@ struct FileSaverHandler: View { case .failure: isFileSaved = false toastType = .error - resultMessage = languageSettings.localized("Failed to save file", [fileURL.lastPathComponent]) + resultMessage = showsFileNameOnFailure + ? languageSettings.localized("Failed to save file", [fileURL.lastPathComponent]) + : languageSettings.localized("Failed to save file message") } Toast.show(resultMessage, type: toastType) diff --git a/RIADigiDoc/ViewModel/DiagnosticsViewModel.swift b/RIADigiDoc/ViewModel/DiagnosticsViewModel.swift index 7673eba5..b9f059e5 100644 --- a/RIADigiDoc/ViewModel/DiagnosticsViewModel.swift +++ b/RIADigiDoc/ViewModel/DiagnosticsViewModel.swift @@ -19,8 +19,8 @@ import CommonsLib import ConfigLib +import Foundation import LibdigidocLibSwift -import OSLog import UtilsLib @Observable @@ -55,6 +55,7 @@ class DiagnosticsViewModel: DiagnosticsViewModelProtocol, Loggable { private let userAgentUtil: UserAgentUtilProtocol private let fileUtil: FileUtilProtocol private let cryptoSetup: CryptoSetupProtocol + private let logCollector: LogCollector private var configurationObservationTask: Task? @@ -78,6 +79,7 @@ class DiagnosticsViewModel: DiagnosticsViewModelProtocol, Loggable { self.userAgentUtil = userAgentUtil self.fileUtil = fileUtil self.cryptoSetup = cryptoSetup + self.logCollector = LogCollector() configurationObservationTask = Task { await observeConfigurationUpdates() @@ -265,11 +267,10 @@ class DiagnosticsViewModel: DiagnosticsViewModelProtocol, Loggable { func createDiagnosticsFile(languageSettings: LanguageSettingsProtocol, directory: URL? = nil) async -> URL? { let diagnosticsText = buildDiagnosticsText(languageSettings: languageSettings) let diagnosticsFileName = "ria_digidoc_\(self.versionSectionContent)_diagnostics.log" - return writeToTempFile( - content: diagnosticsText, - fileName: diagnosticsFileName, - directory: directory - ) + + return await writeTempFile(fileName: diagnosticsFileName, directory: directory) { fileURL in + try await logCollector.write(diagnosticsText, to: fileURL) + } } func onDiagnosticsFileSavingComplete() { @@ -378,15 +379,15 @@ class DiagnosticsViewModel: DiagnosticsViewModelProtocol, Loggable { } public func createLogFile(directory: URL? = nil) async -> URL? { - let appLogEntries = await readAppLogEntries() - let libdigidocLogEntries = await readLibDigidocLogEntries() - let mergedLines = mergeLogEntries(appLogEntries, libdigidocLogEntries) let logFileName = "ria_digidoc_\(self.versionSectionContent).log" - return writeToTempFile( - content: mergedLines, - fileName: logFileName, - directory: directory - ) + + return await writeTempFile(fileName: logFileName, directory: directory) { fileURL in + try await logCollector.writeLogFile( + to: fileURL, + libdigidocLog: getLibDigidocLogURL(), + subsystemPrefix: BundleUtil.getBundleIdentifier() + ) + } } public func onLogFileSavingComplete() async { @@ -414,74 +415,37 @@ class DiagnosticsViewModel: DiagnosticsViewModelProtocol, Loggable { } } - private func writeToTempFile(content: String, fileName: String, directory: URL?) -> URL? { - do { - let fileURL = try getTempFileURL(fileName: fileName, directory: directory) - try content.write(to: fileURL, atomically: true, encoding: .utf8) - return fileURL - } catch { - DiagnosticsViewModel.logger().error("Unable to write \"\(fileName)\" file: \(error)") - } - return nil - } - private func removeAllLogFiles() { fileUtil.removeCacheLogsDirectory() fileUtil.removeLibraryLogsDirectory(directory: nil) } - private func entriesToLines(_ entries: AnySequence) -> [String] { - var lines = [String]() - for entry in entries { - if let log = entry as? OSLogEntryLog { - lines.append(""" - \(entry.date) \ - [\(log.subsystem):\(log.category)] - \ - \(entry.composedMessage) - """) - } else { - lines.append("\(entry.date): \(entry.composedMessage)\n") - } - } - return lines - } - - private func readAppLogEntries() async -> [String]? { - return await withCheckedContinuation { continuation in - Task.detached(priority: .userInitiated) { - do { - let store = try OSLogStore(scope: .currentProcessIdentifier) - let oneDayAgo = Calendar.current.date(byAdding: .day, value: -1, to: Date()) - guard let yesterday = oneDayAgo else { - continuation.resume(returning: nil) - return - } - - let position = store.position(date: yesterday) - let bundleIdentifier = BundleUtil.getBundleIdentifier() - let predicate = NSPredicate( - format: "subsystem BEGINSWITH %@", - bundleIdentifier - ) - let entries = try store.getEntries(at: position, matching: predicate) - let lines = await self.entriesToLines(entries) - continuation.resume(returning: lines) - } catch { - DiagnosticsViewModel.logger().error("Unable to get app log entries: \(error)") - continuation.resume(returning: nil) - } - } - } - } + private func writeTempFile( + fileName: String, + directory: URL?, + write: (URL) async throws -> Void + ) async -> URL? { + let startedAt = ContinuousClock.now + do { + let fileURL = try getTempFileURL(fileName: fileName, directory: directory) + try await write(fileURL) - private func readLibDigidocLogEntries() async -> [String]? { - if let libdigidocLogURL = await getLibDigidocLogURL() { - return getLines(from: libdigidocLogURL) + let elapsed = (ContinuousClock.now - startedAt).formatted(.units(allowed: [.seconds, .milliseconds])) + DiagnosticsViewModel.logger().info( + "Wrote \"\(fileName, privacy: .public)\" in \(elapsed, privacy: .public)") + return fileURL + } catch is CancellationError { + DiagnosticsViewModel.logger().info("Cancelled writing \"\(fileName, privacy: .public)\"") + return nil + } catch { + let reason = String(reflecting: error) + DiagnosticsViewModel.logger().error( + "Unable to write \"\(fileName, privacy: .public)\" file: \(reason, privacy: .public)") + return nil } - return nil } - private func getLibDigidocLogURL() async -> URL? { + private func getLibDigidocLogURL() -> URL? { do { return try Directories.getLibdigidocLogFile( from: Directories.getLibraryDirectory(fileManager: fileManager), @@ -493,36 +457,6 @@ class DiagnosticsViewModel: DiagnosticsViewModelProtocol, Loggable { return nil } - private func getLines(from url: URL?) -> [String] { - guard let url = url, fileManager.fileExists(atPath: url.path) else { return [] } - do { - let content = try String(contentsOf: url, encoding: .utf8) - return content.components(separatedBy: .newlines).filter { !$0.isEmpty } - } catch { - return [] - } - } - - private func mergeLogEntries(_ appLogEntries: [String]?, _ libDigidocLogEntries: [String]?) -> String { - var allEntries: [String] = [] - - allEntries.append("===== File: \(Constants.File.LibDigidocLog) =====") - allEntries.append("") - if let libDigidocLogEntries = libDigidocLogEntries { - allEntries.append(contentsOf: libDigidocLogEntries) - } - - allEntries.append("") - allEntries.append("") - allEntries.append("===== File: ria_digidoc.log =====") - allEntries.append("") - if let appLogEntries = appLogEntries { - allEntries.append(contentsOf: appLogEntries) - } - - return allEntries.joined(separator: "\n") - } - // MARK: - Observer public func observeConfigurationUpdates() async { diff --git a/RIADigiDocTests/ViewModel/DiagnosticsViewModelTests.swift b/RIADigiDocTests/ViewModel/DiagnosticsViewModelTests.swift index 74f8008b..94c2172f 100644 --- a/RIADigiDocTests/ViewModel/DiagnosticsViewModelTests.swift +++ b/RIADigiDocTests/ViewModel/DiagnosticsViewModelTests.swift @@ -303,7 +303,7 @@ final class DiagnosticsViewModelTests { let mockLanguageSettings = LanguageSettingsProtocolMock() await viewModel.getConfigurationData(configuration: mockConfigProvider) - let tempDirectoryURL = try TestFileUtil.getTemporaryDirectory(subfolder: "logfiles") + let tempDirectoryURL = try TestFileUtil.getTemporaryDirectory(subfolder: "logfiles-\(UUID().uuidString)") try FileManager.default.createDirectory(at: tempDirectoryURL, withIntermediateDirectories: true) mockFileManager.urlHandler = { _, _, _, _ in tempDirectoryURL } @@ -314,12 +314,14 @@ final class DiagnosticsViewModelTests { try? FileManager.default.removeItem(at: tempDirectoryURL) } - if let logFileUrl = await viewModel.createDiagnosticsFile( + let diagnosticsFileUrl = await viewModel.createDiagnosticsFile( languageSettings: mockLanguageSettings, directory: tempDirectoryURL - ) { - #expect(!logFileUrl.resolvedPath.isEmpty) - } + ) + + let fileUrl = try #require(diagnosticsFileUrl) + #expect(!fileUrl.resolvedPath.isEmpty) + #expect(FileManager.default.fileExists(atPath: fileUrl.resolvedPath)) } @Test @@ -455,7 +457,7 @@ final class DiagnosticsViewModelTests { @Test func createLogFile_success() async throws { - let tempDirectoryURL = try TestFileUtil.getTemporaryDirectory(subfolder: "logfiles") + let tempDirectoryURL = try TestFileUtil.getTemporaryDirectory(subfolder: "logfiles-\(UUID().uuidString)") mockFileManager.urlHandler = { _, _, _, _ in tempDirectoryURL } mockFileManager.fileExistsHandler = { _ in true } @@ -465,12 +467,16 @@ final class DiagnosticsViewModelTests { try? FileManager.default.removeItem(at: tempDirectoryURL) } - if let logFileUrl = await viewModel.createLogFile( + let logFileUrl = await viewModel.createLogFile( directory: tempDirectoryURL - ) { - #expect(!logFileUrl.resolvedPath.isEmpty) - } + ) + + let fileUrl = try #require(logFileUrl) + #expect(!fileUrl.resolvedPath.isEmpty) + let contents = try String(contentsOf: fileUrl, encoding: .utf8) + #expect(contents.contains("===== File: \(CommonsLib.Constants.File.LibDigidocLog) =====")) + #expect(contents.contains("===== File: \(CommonsLib.Constants.File.AppLog) =====")) } @Test