From f29faedd59857ed53ad1f5cd2e9c4d5ca4fd26f2 Mon Sep 17 00:00:00 2001 From: Marten Rebane Date: Tue, 25 Aug 2026 18:59:40 +0300 Subject: [PATCH] Fix accessibility in password encryption view --- .../Container/Crypto/EncryptView.swift | 22 +-- .../Modal/EncryptPasswordModalView.swift | 21 ++- .../Crypto/Modal/PasswordModalCard.swift | 10 +- .../Recipient/EncryptRecipientView.swift | 10 +- .../Shared/FloatingLabelTextField.swift | 57 +++++--- RIADigiDoc/UI/Component/Toast/Toast.swift | 2 +- .../CryptoFileOpeningViewModel.swift | 3 +- RIADigiDoc/ViewModel/EncryptViewModel.swift | 24 ++-- .../ViewModel/EncryptViewModelTests.swift | 128 ++++++++++++++++++ 9 files changed, 218 insertions(+), 59 deletions(-) create mode 100644 RIADigiDocTests/ViewModel/EncryptViewModelTests.swift diff --git a/RIADigiDoc/UI/Component/Container/Crypto/EncryptView.swift b/RIADigiDoc/UI/Component/Container/Crypto/EncryptView.swift index 09cec498..f6d9bb75 100644 --- a/RIADigiDoc/UI/Component/Container/Crypto/EncryptView.swift +++ b/RIADigiDoc/UI/Component/Container/Crypto/EncryptView.swift @@ -239,10 +239,6 @@ struct EncryptView: View { await updateAsyncLabels() await viewModel.updateAsyncProperties() - Toast.show(languageSettings.localized( - "Container successfully encrypted" - ), type: .success) - encryptionButtonEnabled = true } } @@ -466,9 +462,7 @@ struct EncryptView: View { await updateAsyncLabels() await viewModel.updateAsyncProperties() - Toast.show(languageSettings.localized( - "Container successfully decrypted" - ), type: .success) + showMessage("Container successfully decrypted", type: .success) isWithDecryption = false } else { await viewModel.loadContainerData( @@ -579,7 +573,7 @@ struct EncryptView: View { private func handlePasswordDecrypt(_ password: String) async { guard let containerFile = viewModel.containerURL else { - Toast.show(languageSettings.localized("Decrypt general error")) + showMessage("Decrypt general error") return } do { @@ -596,14 +590,20 @@ struct EncryptView: View { selectedTab = .files await updateAsyncLabels() await viewModel.updateAsyncProperties() - Toast.show(languageSettings.localized("Container successfully decrypted"), type: .success) + showMessage("Container successfully decrypted", type: .success) } catch CryptoError.wrongDecryptionKey { - Toast.show(languageSettings.localized("Decrypt wrong password error")) + showMessage("Decrypt wrong password error") } catch { - Toast.show(languageSettings.localized("Decrypt general error")) + showMessage("Decrypt general error") } } + private func showMessage(_ key: String, type: ToastType = .error) { + let message = languageSettings.localized(key) + Toast.show(message, type: type) + AccessibilityUtil.announceMessage(message) + } + func updateAsyncLabels() async { let containerTitle = await containerTitle() let encryptDecryptLabel = await self.encryptDecryptLabel() diff --git a/RIADigiDoc/UI/Component/Container/Crypto/Modal/EncryptPasswordModalView.swift b/RIADigiDoc/UI/Component/Container/Crypto/Modal/EncryptPasswordModalView.swift index c48d25b4..8934eeae 100644 --- a/RIADigiDoc/UI/Component/Container/Crypto/Modal/EncryptPasswordModalView.swift +++ b/RIADigiDoc/UI/Component/Container/Crypto/Modal/EncryptPasswordModalView.swift @@ -28,6 +28,11 @@ struct EncryptPasswordModalView: View { @State private var keyLabel: String = "" @State private var password: String = "" @State private var repeatPassword: String = "" + @FocusState private var focusedField: String? + + private let keyLabelFieldId = "passwordKeyLabel" + private let passwordFieldId = "passwordInput" + private let repeatPasswordFieldId = "repeatPasswordInput" let onEncrypt: (String, String) -> Void let onCancel: () -> Void @@ -79,8 +84,11 @@ struct EncryptPasswordModalView: View { placeholder: keyLabelTitle, text: $keyLabel, submitLabel: .next, - identifier: "passwordKeyLabel", - accessibilityHint: languageSettings.localized("Crypto password key label description") + identifier: keyLabelFieldId, + accessibilityHint: languageSettings.localized("Crypto password key label description"), + textContentType: .oneTimeCode, + sharedFocus: $focusedField, + nextFocus: passwordFieldId ) Text(verbatim: languageSettings.localized("Crypto password key label description")) .font(typography.labelMedium) @@ -141,8 +149,10 @@ struct EncryptPasswordModalView: View { isSecure: true, isError: showPasswordError, submitLabel: .next, - identifier: "passwordInput", - sortPriority: 0 + identifier: passwordFieldId, + sortPriority: 0, + sharedFocus: $focusedField, + nextFocus: repeatPasswordFieldId ) VStack(alignment: .leading, spacing: Dimensions.Padding.ZeroPadding) { ForEach(EncryptPasswordModalView.requirementKeys, id: \.self) { key in @@ -167,7 +177,8 @@ struct EncryptPasswordModalView: View { ? languageSettings.localized("Crypto password repeat mismatch") : "", submitLabel: .done, - identifier: "repeatPasswordInput" + identifier: repeatPasswordFieldId, + sharedFocus: $focusedField ) } diff --git a/RIADigiDoc/UI/Component/Container/Crypto/Modal/PasswordModalCard.swift b/RIADigiDoc/UI/Component/Container/Crypto/Modal/PasswordModalCard.swift index 4760abd2..88936c68 100644 --- a/RIADigiDoc/UI/Component/Container/Crypto/Modal/PasswordModalCard.swift +++ b/RIADigiDoc/UI/Component/Container/Crypto/Modal/PasswordModalCard.swift @@ -68,6 +68,7 @@ struct PasswordModalCard: View { } struct PasswordModalTitleView: View { + @Environment(\.accessibilityVoiceOverEnabled) private var voiceOverEnabled @AppTheme private var theme @AppTypography private var typography @@ -84,11 +85,10 @@ struct PasswordModalTitleView: View { .accessibilityHeading(.h1) .accessibilityAddTraits([.isHeader]) .accessibilityFocused($isFocused) - .onAppear { - Task { - try? await Task.sleep(for: .seconds(0.3)) - isFocused = true - } + .task(id: voiceOverEnabled) { + guard voiceOverEnabled else { return } + try? await Task.sleep(for: .seconds(0.3)) + isFocused = true } } } diff --git a/RIADigiDoc/UI/Component/Container/Crypto/Recipient/EncryptRecipientView.swift b/RIADigiDoc/UI/Component/Container/Crypto/Recipient/EncryptRecipientView.swift index d4e5a7ab..3381bef9 100644 --- a/RIADigiDoc/UI/Component/Container/Crypto/Recipient/EncryptRecipientView.swift +++ b/RIADigiDoc/UI/Component/Container/Crypto/Recipient/EncryptRecipientView.swift @@ -505,15 +505,21 @@ struct EncryptRecipientView: View { do { try await viewModel.encryptWithPassword(label: label, password: password) showPasswordEncryptModal = false + showMessage("Container successfully encrypted", type: .success) pathManager.replaceLast( to: .encryptView(isWithEncryption: false, cdocOption: cdocOption, selectedTab: .recipients) ) - Toast.show(languageSettings.localized("Container successfully encrypted"), type: .success) } catch { - Toast.show(languageSettings.localized("Encrypt general error")) + showMessage("Encrypt general error") } } + private func showMessage(_ key: String, type: ToastType = .error) { + let message = languageSettings.localized(key) + Toast.show(message, type: type) + AccessibilityUtil.announceMessage(message) + } + private func emptyStateView(_ text: String) -> some View { ContentUnavailableView { Text(verbatim: text) diff --git a/RIADigiDoc/UI/Component/Shared/FloatingLabelTextField.swift b/RIADigiDoc/UI/Component/Shared/FloatingLabelTextField.swift index bf979736..931a1301 100644 --- a/RIADigiDoc/UI/Component/Shared/FloatingLabelTextField.swift +++ b/RIADigiDoc/UI/Component/Shared/FloatingLabelTextField.swift @@ -51,6 +51,9 @@ struct FloatingLabelTextField: View { let spellOutCharacters: Bool let showBorder: Bool let accessibilityHint: String + let textContentType: UITextContentType? + let sharedFocus: FocusState.Binding? + let nextFocus: String? let onDone: (() -> Void) init( @@ -71,6 +74,9 @@ struct FloatingLabelTextField: View { spellOutCharacters: Bool = false, showBorder: Bool = true, accessibilityHint: String = "", + textContentType: UITextContentType? = nil, + sharedFocus: FocusState.Binding? = nil, + nextFocus: String? = nil, onDone: @escaping (() -> Void) = {} ) { self.title = title @@ -90,13 +96,20 @@ struct FloatingLabelTextField: View { self.spellOutCharacters = spellOutCharacters self.showBorder = showBorder self.accessibilityHint = accessibilityHint + self.textContentType = textContentType + self.sharedFocus = sharedFocus + self.nextFocus = nextFocus self.onDone = onDone } // MARK: - State @State private var isPasswordVisible: Bool = false @State private var isFocused: Bool = false - @FocusState private var fieldIsFocused: Bool + @FocusState private var privateFocus: String? + + private var focus: FocusState.Binding { sharedFocus ?? $privateFocus } + private var focusKey: String { identifier.isEmpty ? "field" : identifier } + private var fieldIsFocused: Bool { focus.wrappedValue == focusKey } // MARK: - Computed properties @@ -118,7 +131,10 @@ struct FloatingLabelTextField: View { // Dont show password saving options private var fieldContentType: UITextContentType? { - isSecure ? .oneTimeCode : .init(rawValue: "") + if let textContentType { + return textContentType + } + return isSecure ? .oneTimeCode : .init(rawValue: "") } private var isInteractionEnabled: Bool { @@ -323,17 +339,10 @@ struct FloatingLabelTextField: View { contentType: fieldContentType, isAccessibilityFocused: $isAccessibilityFocused, onAppear: {}, - onSubmit: { - isFocused = false - isAccessibilityFocused = true - onDone() - } + onSubmit: submit ) .privacySensitive() .toolbar { keyboardToolbar } - .onChange(of: errorText, { _, newValue in - AccessibilityUtil.announceMessage(newValue) - }) .accessibilitySortPriority(sortPriority) .accessibilityLabel(Text(verbatim: title)) } else { @@ -353,11 +362,7 @@ struct FloatingLabelTextField: View { onAppear: { selection = TextSelection(insertionPoint: text.endIndex) }, - onSubmit: { - fieldIsFocused = false - isAccessibilityFocused = true - onDone() - } + onSubmit: submit ) .toolbar { keyboardToolbar } .accessibilitySortPriority(sortPriority) @@ -366,8 +371,9 @@ struct FloatingLabelTextField: View { } .font(typography.bodyLarge) .foregroundStyle(textColor) - .focused($fieldIsFocused) - .onChange(of: fieldIsFocused) { _, newValue in + .focused(focus, equals: focusKey) + .onChange(of: focus.wrappedValue) { _, newFocus in + let newValue = newFocus == focusKey if isInteractionEnabled { withAnimation(.easeInOut(duration: Dimensions.Duration.focusAnimation)) { isFocused = newValue @@ -375,6 +381,7 @@ struct FloatingLabelTextField: View { } } .onChange(of: errorText, { _, newValue in + guard !newValue.isEmpty else { return } AccessibilityUtil.announceMessage(newValue) }) .accessibilityHint(Text(verbatim: accessibilityHint)) @@ -406,13 +413,19 @@ struct FloatingLabelTextField: View { ) } + private func submit() { + if let nextFocus { + focus.wrappedValue = nextFocus + } else { + focus.wrappedValue = nil + isAccessibilityFocused = true + } + onDone() + } + private var doneButton: some View { Button( - action: { - fieldIsFocused = false - isAccessibilityFocused = true - onDone() - }, + action: submit, label: { Text(verbatim: languageSettings.localized("Done")) } ) } diff --git a/RIADigiDoc/UI/Component/Toast/Toast.swift b/RIADigiDoc/UI/Component/Toast/Toast.swift index 4b62a1ba..3e7c0313 100644 --- a/RIADigiDoc/UI/Component/Toast/Toast.swift +++ b/RIADigiDoc/UI/Component/Toast/Toast.swift @@ -22,7 +22,7 @@ import SwiftUI struct Toast { static func show( _ message: String, - duration: TimeInterval = 5.0, + duration: TimeInterval = 4.0, type: ToastType = .error ) { if message.isEmpty { return } diff --git a/RIADigiDoc/ViewModel/CryptoFileOpeningViewModel.swift b/RIADigiDoc/ViewModel/CryptoFileOpeningViewModel.swift index 1f6994a1..375df7ce 100644 --- a/RIADigiDoc/ViewModel/CryptoFileOpeningViewModel.swift +++ b/RIADigiDoc/ViewModel/CryptoFileOpeningViewModel.swift @@ -186,7 +186,8 @@ class CryptoFileOpeningViewModel: CryptoFileOpeningViewModelProtocol, Loggable { CryptoFileOpeningViewModel.logger().error("\(dde)") errorMessage = createToastMessage(for: dde) } else { - errorMessage = ToastMessage(key: error.localizedDescription) + CryptoFileOpeningViewModel.logger().error("\(error.localizedDescription)") + errorMessage = ToastMessage(key: "General error") } } diff --git a/RIADigiDoc/ViewModel/EncryptViewModel.swift b/RIADigiDoc/ViewModel/EncryptViewModel.swift index d7c5b193..18ab7bbe 100644 --- a/RIADigiDoc/ViewModel/EncryptViewModel.swift +++ b/RIADigiDoc/ViewModel/EncryptViewModel.swift @@ -53,6 +53,9 @@ class EncryptViewModel: EncryptViewModelProtocol, Loggable { private let fileInspector: FileInspectorProtocol private let sivaRepository: SivaRepositoryProtocol + var encryptAction: (URL, [URL], [Addressee]) async throws -> any CryptoContainerProtocol = + CryptoContainer.encrypt + private(set) var cryptoContainer: CryptoContainerProtocol? private(set) var isContainerWithoutRecipients = false @@ -297,14 +300,16 @@ class EncryptViewModel: EncryptViewModelProtocol, Loggable { public func encryptContainer() async { await loadContainerData(cryptoContainer: nil) - guard let cryptoContainer else { return } - guard let containerFile = await cryptoContainer.getRawContainerFile() else { return } + guard let cryptoContainer else { + errorMessage = ToastMessage(key: "Encrypt general error", args: []) + return + } + guard let containerFile = await cryptoContainer.getRawContainerFile() else { + errorMessage = ToastMessage(key: "Encrypt general error", args: []) + return + } do { - let encryptedContainer = try await CryptoContainer.encrypt( - containerFile: containerFile, - dataFiles: dataFiles, - recipients: recipients - ) + let encryptedContainer = try await encryptAction(containerFile, dataFiles, recipients) sharedContainerViewModel.clearContainers() sharedContainerViewModel.setCryptoContainer(encryptedContainer) await loadContainerData( @@ -329,11 +334,6 @@ class EncryptViewModel: EncryptViewModelProtocol, Loggable { return } - if let nsError = error as NSError? { - errorMessage = ToastMessage(key: nsError.localizedDescription, args: []) - return - } - errorMessage = ToastMessage(key: "Encrypt general error", args: []) } diff --git a/RIADigiDocTests/ViewModel/EncryptViewModelTests.swift b/RIADigiDocTests/ViewModel/EncryptViewModelTests.swift new file mode 100644 index 00000000..52388750 --- /dev/null +++ b/RIADigiDocTests/ViewModel/EncryptViewModelTests.swift @@ -0,0 +1,128 @@ +/* + * 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 CommonsLibMocks +import CryptoSwift +import Foundation +import LibdigidocLibSwiftMocks +import Testing +import UtilsLibMocks + +@MainActor +struct EncryptViewModelTests { + + private let viewModel: EncryptViewModel + private let mockSharedContainerViewModel = SharedContainerViewModelProtocolMock() + + init() { + viewModel = EncryptViewModel( + sharedContainerViewModel: mockSharedContainerViewModel, + fileOpeningService: FileOpeningServiceProtocolMock(), + mimeTypeCache: MimeTypeCacheProtocolMock(), + mimeTypeDecoder: MimeTypeDecoderProtocolMock(), + fileUtil: FileUtilProtocolMock(), + fileManager: FileManagerProtocolMock(), + fileInspector: FileInspectorProtocolMock(), + sivaRepository: SivaRepositoryProtocolMock() + ) + } + + private func stubContainer() -> CryptoContainerProtocolMock { + let container = CryptoContainerProtocolMock() + container.getRawContainerFileHandler = { URL(filePath: "/mock/path/to/container.cdoc2") } + container.getContainerNameHandler = { "container.cdoc2" } + container.getDataFilesHandler = { [URL(filePath: "/mock/path/to/text.txt")] } + container.getRecipientsHandler = { [] } + container.getContainerMimetypeHandler = { CommonsLib.Constants.MimeType.Cdoc } + mockSharedContainerViewModel.currentContainerHandler = { container } + return container + } + + @Test + func encryptContainer_reportsSuccessOnlyWhenEncryptionSucceeds() async { + _ = stubContainer() + let encrypted = CryptoContainerProtocolMock() + encrypted.getRawContainerFileHandler = { URL(filePath: "/mock/path/to/container.cdoc2") } + encrypted.getContainerNameHandler = { "container.cdoc2" } + encrypted.getDataFilesHandler = { [] } + encrypted.getRecipientsHandler = { [] } + encrypted.getContainerMimetypeHandler = { CommonsLib.Constants.MimeType.Cdoc } + viewModel.encryptAction = { _, _, _ in encrypted } + + await viewModel.encryptContainer() + + #expect(viewModel.successMessage == ToastMessage(key: "Container successfully encrypted", args: [])) + #expect(viewModel.errorMessage == nil) + #expect(mockSharedContainerViewModel.setCryptoContainerCallCount == 1) + } + + @Test + func encryptContainer_reportsOnlyTheErrorWhenEncryptionFails() async { + _ = stubContainer() + viewModel.encryptAction = { _, _, _ in + throw CryptoError.containerCreationFailed( + CryptoErrorDetail(message: "Cannot create an empty crypto container") + ) + } + + await viewModel.encryptContainer() + + #expect(viewModel.errorMessage == ToastMessage(key: "Cannot create an empty crypto container", args: [])) + #expect(viewModel.successMessage == nil) + #expect(mockSharedContainerViewModel.setCryptoContainerCallCount == 0) + } + + @Test + func encryptContainer_reportsAnErrorWhenThereIsNoContainer() async { + mockSharedContainerViewModel.currentContainerHandler = { nil } + + await viewModel.encryptContainer() + + #expect(viewModel.errorMessage == ToastMessage(key: "Encrypt general error", args: [])) + #expect(viewModel.successMessage == nil) + } + + @Test + func encryptContainer_usesTranslatableKeyForNativeError() async { + _ = stubContainer() + viewModel.encryptAction = { _, _, _ in + throw NSError( + domain: "ee.ria.digidoc.CryptoLib", + code: 1000, + userInfo: [NSLocalizedDescriptionKey: "Failed to start encryption"] + ) + } + + await viewModel.encryptContainer() + + #expect(viewModel.errorMessage == ToastMessage(key: "Encrypt general error", args: [])) + #expect(viewModel.successMessage == nil) + } + + @Test + func encryptContainer_usesGeneralKeyForOtherCryptoErrors() async { + _ = stubContainer() + viewModel.encryptAction = { _, _, _ in throw CryptoError.wrongDecryptionKey } + + await viewModel.encryptContainer() + + #expect(viewModel.errorMessage == ToastMessage(key: "Encrypt general error", args: [])) + } +}