Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@ public enum MobileIdError: Error {
case invalidAccessRights
case invalidSslHandshake
case technicalError
case requestInterrupted
}
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,11 @@ struct ResponseHandler: ResponseHandlerProtocol {
}

func handleStatusCodeError(_ statusCode: Int?) throws {
switch statusCode ?? -1 {
guard let statusCode else {
throw MobileIdError.requestInterrupted
}

switch statusCode {
case 400:
throw MobileIdError.incorrectParameters
case 401:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,16 +96,33 @@ actor MobileIdSignService: MobileIdSignServiceProtocol, Loggable {
userAgent: String
) async throws -> MobileIdSessionResponse {
let pollingTimeoutMs = pollingTimeout * 1000
var retriedPolls = 0

while true {
let sessionResponse: MobileIdSessionResponse? = try await requestPerformer.performRequest(
url: "\(url)/\(sessionId)",
method: .get,
parameters: ["timeoutMs": pollingTimeoutMs],
trustedCertificates: trustedCertificates,
proxyInfo: proxyInfo,
userAgent: userAgent
)
try Task.checkCancellation()

let sessionResponse: MobileIdSessionResponse?

do {
sessionResponse = try await requestPerformer.performRequest(
url: "\(url)/\(sessionId)",
method: .get,
parameters: ["timeoutMs": pollingTimeoutMs],
trustedCertificates: trustedCertificates,
proxyInfo: proxyInfo,
userAgent: userAgent
)
} catch MobileIdError.requestInterrupted {
retriedPolls += 1

guard retriedPolls <= Constants.Signing.MaxPollRetries else {
throw MobileIdError.requestInterrupted
}

continue
}

retriedPolls = 0

if let response = sessionResponse,
response.state == .complete {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,22 @@ struct ResponseHandlerTests {
}
}

@Test
func handleNetworkError_throwsRequestInterruptedWhenNoUrlErrorAndNoStatusCode() {
let afError = AFError.serverTrustEvaluationFailed(reason: .noRequiredEvaluator(host: "host.test"))

#expect(throws: MobileIdError.requestInterrupted) {
try handler.handleNetworkError(afError, statusCode: nil)
}
}

@Test
func handleStatusCodeError_throwsRequestInterruptedWhenStatusCodeIsMissing() {
#expect(throws: MobileIdError.requestInterrupted) {
try handler.handleStatusCodeError(nil)
}
}

@Test
func handleStatusCodeError_throwsStatusCodeErrors() {
#expect(throws: MobileIdError.incorrectParameters) { try handler.handleStatusCodeError(400) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,4 +140,133 @@ struct MobileIdSignServiceTests {

#expect(code == nil)
}

@Test
func getSessionRequest_retriesPollWhenRequestIsInterrupted() async throws {
let completedResponse = MobileIdSessionResponse(
state: .complete,
result: .ok,
signature: MobileIdSessionSignatureResponse(
value: Data([0xAA, 0xBB]),
algorithm: "TestAlgorithm"
),
cert: "TestCert",
time: Date.now.formatted(),
traceId: "TestTraceId"
)

mockRequestPerfomer.performRequestHandler = { [mockRequestPerfomer] _, _, _, _, _, _ in
if mockRequestPerfomer.performRequestCallCount == 1 {
throw MobileIdError.requestInterrupted
}
return completedResponse
}

let response = try await service.getSessionRequest(
url: "https://url.test/session",
sessionId: "abc",
pollingTimeout: 1,
trustedCertificates: [],
proxyInfo: ProxyInfo(),
userAgent: "MobileIdSignServiceTestsUserAgent"
)

#expect(response.state == .complete)
#expect(mockRequestPerfomer.performRequestCallCount == 2)
}

@Test
func getSessionRequest_throwsRequestInterruptedWhenEveryRetryIsInterrupted() async {
mockRequestPerfomer.performRequestHandler = { _, _, _, _, _, _ in
throw MobileIdError.requestInterrupted
}

await #expect(throws: MobileIdError.requestInterrupted) {
try await service.getSessionRequest(
url: "https://url.test/session",
sessionId: "abc",
pollingTimeout: 1,
trustedCertificates: [],
proxyInfo: ProxyInfo(),
userAgent: "MobileIdSignServiceTestsUserAgent"
)
}

#expect(mockRequestPerfomer.performRequestCallCount == Constants.Signing.MaxPollRetries + 1)
}

@Test
func getSessionRequest_resetsRetryCountAfterASuccessfulPoll() async throws {
let runningResponse = MobileIdSessionResponse(
state: .running,
result: nil,
signature: nil,
cert: nil,
time: Date.now.formatted(),
traceId: "TestTraceId"
)
let completedResponse = MobileIdSessionResponse(
state: .complete,
result: .ok,
signature: MobileIdSessionSignatureResponse(
value: Data([0xAA, 0xBB]),
algorithm: "TestAlgorithm"
),
cert: "TestCert",
time: Date.now.formatted(),
traceId: "TestTraceId"
)

// Interrupt, recover, interrupt again: the second run of interruptions must get a full
// retry budget, so the poll still completes instead of giving up.
mockRequestPerfomer.performRequestHandler = { [mockRequestPerfomer] _, _, _, _, _, _ in
switch mockRequestPerfomer.performRequestCallCount {
case 1, 2, 3, 5, 6, 7:
throw MobileIdError.requestInterrupted
case 4:
return runningResponse
default:
return completedResponse
}
}

let response = try await service.getSessionRequest(
url: "https://url.test/session",
sessionId: "abc",
pollingTimeout: 1,
trustedCertificates: [],
proxyInfo: ProxyInfo(),
userAgent: "MobileIdSignServiceTestsUserAgent"
)

#expect(response.state == .complete)
#expect(mockRequestPerfomer.performRequestCallCount == 8)
}

@Test
func getSessionRequest_endsWithCancellationErrorWhenTaskIsCancelled() async {
// Tearing the signing screen down (for example when a container opened from another app
// replaces the one being signed) cancels the task mid-poll. That must surface as a
// CancellationError, which the view model swallows, and never as a MobileIdError that
// would show the user an error about the container they just left.
mockRequestPerfomer.performRequestHandler = { _, _, _, _, _, _ in
throw MobileIdError.requestInterrupted
}

let task = Task { [service] in
try await service.getSessionRequest(
url: "https://url.test/session",
sessionId: "abc",
pollingTimeout: 1,
trustedCertificates: [],
proxyInfo: ProxyInfo(),
userAgent: "MobileIdSignServiceTestsUserAgent"
)
}
task.cancel()

await #expect(throws: CancellationError.self) {
_ = try await task.value
}
}
}
3 changes: 2 additions & 1 deletion RIADigiDoc/DI/AppContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,8 @@ extension Container {
var signingRootViewModel: Factory<SigningRootViewModel> {
self { @MainActor in
SigningRootViewModel(
dataStore: self.dataStore()
dataStore: self.dataStore(),
sharedContainerViewModel: self.sharedContainerViewModel()
)
}
}
Expand Down
12 changes: 3 additions & 9 deletions RIADigiDoc/UI/Component/Container/Signing/SigningRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,7 @@ struct SigningRootView: View {
signedContainer: container,
onSuccess: { container in
isSuccess = true
sharedContainerViewModel.removeLastContainer()
sharedContainerViewModel.setSignedContainer(container)
sharedContainerViewModel.setIsSignatureAdded(true)
viewModel.applySignedContainer(container, replacing: signedContainer)
}
)
}
Expand All @@ -55,9 +53,7 @@ struct SigningRootView: View {
MobileIdView(
signedContainer: container,
onSuccess: { container in
sharedContainerViewModel.removeLastContainer()
sharedContainerViewModel.setSignedContainer(container)
sharedContainerViewModel.setIsSignatureAdded(true)
viewModel.applySignedContainer(container, replacing: signedContainer)
}
)
}
Expand All @@ -66,9 +62,7 @@ struct SigningRootView: View {
SmartIdView(
signedContainer: container,
onSuccess: { container in
sharedContainerViewModel.removeLastContainer()
sharedContainerViewModel.setSignedContainer(container)
sharedContainerViewModel.setIsSignatureAdded(true)
viewModel.applySignedContainer(container, replacing: signedContainer)
}
)
}
Expand Down
19 changes: 18 additions & 1 deletion RIADigiDoc/UI/Component/HomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -293,14 +293,31 @@ struct HomeView: View {
}

private func handleFiles(_ files: [URL]) {
if !files.isEmpty {
guard !files.isEmpty else { return }

dismissHomeModals()
ModalPresentationUtil.dismissPresentedModals()

DispatchQueue.main.async {
pathManager.popToRoot()
viewModel.closeOpenContainers()
isFileOpeningLoading = true
viewModel.isImporting = false
viewModel.setFileOpeningMethod(.all)
viewModel.setChosenFiles(.success(files))
}
}

private func dismissHomeModals() {
showFilesBottomSheet = false
showSignatureBottomSheet = false
showCryptoBottomSheet = false
showHomeMenuBottomSheet = false
isAllFilesImporting = false
isSigningImporting = false
isCryptoImporting = false
}

private func handleIncoming(url: URL) {
let webEidURL = (WebEidUriUtil.isWebEidUri(url)) ? url : nil

Expand Down
35 changes: 35 additions & 0 deletions RIADigiDoc/Util/Presentation/ModalPresentationUtil.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* 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 UIKit

class ModalPresentationUtil {

@MainActor
public static func dismissPresentedModals() {
let rootViewControllers = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
.compactMap { $0.rootViewController }

for rootViewController in rootViewControllers where rootViewController.presentedViewController != nil {
rootViewController.dismiss(animated: false)
}
}
}
6 changes: 6 additions & 0 deletions RIADigiDoc/ViewModel/HomeViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ class HomeViewModel: HomeViewModelProtocol, Loggable {
sharedContainerViewModel.setFileOpeningMethod(method)
}

func closeOpenContainers() {
sharedContainerViewModel.setSignedContainer(nil)
sharedContainerViewModel.setCryptoContainer(nil)
sharedContainerViewModel.clearContainers()
}

func getRecentDocumentsFolder() -> URL? {
do {
return try Directories.getCacheDirectory(fileManager: fileManager)
Expand Down
1 change: 1 addition & 0 deletions RIADigiDoc/ViewModel/Protocols/HomeViewModelProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ public protocol HomeViewModelProtocol: Sendable {
func getRecentDocumentsFolder() -> URL?
func getSharedFiles() async -> [URL]
func setFileOpeningMethod(_ method: FileOpeningMethod)
func closeOpenContainers()
}
12 changes: 11 additions & 1 deletion RIADigiDoc/ViewModel/Signing/MobileId/MobileIdViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,11 @@ class MobileIdViewModel: MobileIdViewModelProtocol, Loggable {
private func handleSigningError(_ error: Error) {
MobileIdViewModel.logger().error("Unable to sign container with Mobile-ID: \(error)")

guard !Task.isCancelled else {
MobileIdViewModel.logger().info("Mobile-ID signing cancelled. Not showing the error")
return
}

if let cancellationError = error as? CancellationError {
MobileIdViewModel.logger().error("Mobile-ID signing manually cancelled: \(cancellationError)")
// Do not throw an error if user cancelled signing with back button
Expand Down Expand Up @@ -451,7 +456,7 @@ class MobileIdViewModel: MobileIdViewModelProtocol, Loggable {
case .simError:
mobileIdErrorMessageKey = "SIM error"

case .noInternetConnection:
case .noInternetConnection, .requestInterrupted:
mobileIdErrorMessageKey = "No Internet connection"

case .tooManyRequests:
Expand Down Expand Up @@ -482,6 +487,11 @@ class MobileIdViewModel: MobileIdViewModelProtocol, Loggable {
private func handleSignatureAddingError(_ error: Error) {
MobileIdViewModel.logger().error("Unable to sign container with Mobile-ID: \(error)")

guard !Task.isCancelled else {
MobileIdViewModel.logger().info("Mobile-ID signing cancelled. Not showing the error")
return
}

if let cancellationError = error as? CancellationError {
MobileIdViewModel.logger().error("Mobile-ID signing manually cancelled: \(cancellationError)")
return
Expand Down
Loading
Loading