From cdffabe91b736c50eeeda49d8168b50fe27e7fe2 Mon Sep 17 00:00:00 2001 From: Felippe Costa Date: Sun, 8 Feb 2026 21:56:26 -0300 Subject: [PATCH 1/4] feat: add live session countdown timer and session expired recovery UI --- .../xcschemes/ExampleApp.xcscheme | 2 +- ExampleApp/ExampleApp/ContentView.swift | 61 +++++++++++++++---- 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/ExampleApp/ExampleApp.xcodeproj/xcshareddata/xcschemes/ExampleApp.xcscheme b/ExampleApp/ExampleApp.xcodeproj/xcshareddata/xcschemes/ExampleApp.xcscheme index 4768ae2..6c6cfc0 100644 --- a/ExampleApp/ExampleApp.xcodeproj/xcshareddata/xcschemes/ExampleApp.xcscheme +++ b/ExampleApp/ExampleApp.xcodeproj/xcshareddata/xcschemes/ExampleApp.xcscheme @@ -31,7 +31,7 @@ shouldAutocreateTestPlan = "YES"> 0 { - Text("Expires in \(Int(remaining))s") + if sessionTimeRemaining > 0 { + Text("Expires in \(sessionTimeRemaining)s") .font(.caption2) - .foregroundColor(remaining < 30 ? .orange : .gray) + .foregroundColor(sessionTimeRemaining < 30 ? .orange : .gray) } } .padding(8) @@ -511,6 +540,7 @@ struct ContentView: View { do { let session = try await client.createCaptureSession() currentSession = session + sessionExpired = false } catch { errorMessage = error.localizedDescription } @@ -559,6 +589,11 @@ struct ContentView: View { // Clear session after successful exchange (one-time use) currentSession = nil + } catch SignedShotAPIError.sessionExpired { + isExchangingToken = false + currentSession = nil + sessionExpired = true + lastCapturedPhoto = nil } catch { isExchangingToken = false errorMessage = error.localizedDescription From f47b70672f4e8e916810ce9bd6fae6948855767a Mon Sep 17 00:00:00 2001 From: Felippe Costa Date: Sun, 8 Feb 2026 22:00:17 -0300 Subject: [PATCH 2/4] feat: add retry button on error alerts for network failures --- ExampleApp/ExampleApp/ContentView.swift | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/ExampleApp/ExampleApp/ContentView.swift b/ExampleApp/ExampleApp/ContentView.swift index 83e9110..1e248bd 100644 --- a/ExampleApp/ExampleApp/ContentView.swift +++ b/ExampleApp/ExampleApp/ContentView.swift @@ -16,6 +16,7 @@ struct ContentView: View { @StateObject private var captureService = CaptureService() @State private var lastCapturedPhoto: CapturedPhoto? @State private var errorMessage: String? + @State private var retryAction: (() async -> Void)? @State private var savedPhotoURL: URL? @State private var isSetup = false @@ -136,7 +137,20 @@ struct ContentView: View { } } .alert("Error", isPresented: .constant(errorMessage != nil)) { - Button("OK") { errorMessage = nil } + if let retry = retryAction { + Button("Retry") { + errorMessage = nil + let action = retry + retryAction = nil + Task { await action() } + } + Button("Dismiss", role: .cancel) { + errorMessage = nil + retryAction = nil + } + } else { + Button("OK") { errorMessage = nil } + } } message: { Text(errorMessage ?? "") } @@ -504,6 +518,7 @@ struct ContentView: View { deviceId = response.deviceId } catch { errorMessage = error.localizedDescription + retryAction = { await registerDevice() } } } @@ -543,6 +558,7 @@ struct ContentView: View { sessionExpired = false } catch { errorMessage = error.localizedDescription + retryAction = { await startSession() } } } @@ -597,6 +613,7 @@ struct ContentView: View { } catch { isExchangingToken = false errorMessage = error.localizedDescription + retryAction = { await capturePhoto() } } } From e40d40254d0a6b3622d05b2bec00541f6b14aebb Mon Sep 17 00:00:00 2001 From: Felippe Costa Date: Sun, 8 Feb 2026 22:03:12 -0300 Subject: [PATCH 3/4] feat: auto-recover from 401 by clearing credentials and prompting re-registration --- ExampleApp/ExampleApp/ContentView.swift | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/ExampleApp/ExampleApp/ContentView.swift b/ExampleApp/ExampleApp/ContentView.swift index 1e248bd..addfa01 100644 --- a/ExampleApp/ExampleApp/ContentView.swift +++ b/ExampleApp/ExampleApp/ContentView.swift @@ -522,6 +522,17 @@ struct ContentView: View { } } + private func handleUnauthorized() async { + logger.warning("Unauthorized: clearing credentials and prompting re-registration") + try? await client.clearStoredCredentials() + isDeviceRegistered = false + deviceId = nil + currentSession = nil + trustToken = nil + sessionExpired = false + errorMessage = "Your device session has expired. Please register again." + } + private func resetDevice() async { do { try await client.clearStoredCredentials() @@ -556,6 +567,8 @@ struct ContentView: View { let session = try await client.createCaptureSession() currentSession = session sessionExpired = false + } catch SignedShotAPIError.unauthorized { + await handleUnauthorized() } catch { errorMessage = error.localizedDescription retryAction = { await startSession() } @@ -610,6 +623,10 @@ struct ContentView: View { currentSession = nil sessionExpired = true lastCapturedPhoto = nil + } catch SignedShotAPIError.unauthorized { + isExchangingToken = false + lastCapturedPhoto = nil + await handleUnauthorized() } catch { isExchangingToken = false errorMessage = error.localizedDescription From 88ba8173943eb7c17e116df10b66cb8817fba998 Mon Sep 17 00:00:00 2001 From: Felippe Costa Date: Sun, 8 Feb 2026 22:07:23 -0300 Subject: [PATCH 4/4] feat: improve user-facing error messages to be friendlier and actionable --- Sources/SignedShotSDK/APIModels.swift | 31 ++++++++++++---------- Sources/SignedShotSDK/CaptureService.swift | 2 +- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/Sources/SignedShotSDK/APIModels.swift b/Sources/SignedShotSDK/APIModels.swift index eca5baa..8babef7 100644 --- a/Sources/SignedShotSDK/APIModels.swift +++ b/Sources/SignedShotSDK/APIModels.swift @@ -101,27 +101,30 @@ public enum SignedShotAPIError: Error, LocalizedError { public var errorDescription: String? { switch self { case .invalidURL: - return "Invalid API URL" - case .networkError(let error): - return "Network error: \(error.localizedDescription)" - case .httpError(let statusCode, let message): - return "HTTP error \(statusCode): \(message ?? "Unknown error")" - case .decodingError(let error): - return "Failed to decode response: \(error.localizedDescription)" + return "Unable to connect to the server. Please try again later." + case .networkError: + return "No internet connection. Check your network and try again." + case .httpError(let statusCode, _): + if statusCode >= 500 { + return "The server is temporarily unavailable. Please try again later." + } + return "Something went wrong. Please try again." + case .decodingError: + return "Received an unexpected response from the server. Please try again." case .deviceAlreadyRegistered: - return "Device is already registered" + return "This device is already registered." case .invalidPublisherId: - return "Invalid publisher ID format" + return "Configuration error. Please reinstall the app." case .unauthorized: - return "Unauthorized - invalid or expired token" + return "Your device session has expired. Please register again." case .notFound: - return "Resource not found" + return "The requested resource was not found." case .deviceNotRegistered: - return "Device must be registered before creating capture sessions" + return "Please register your device first." case .invalidNonce: - return "Invalid or already used nonce" + return "This capture session has already been used. Please start a new session." case .sessionExpired: - return "Capture session has expired" + return "Your capture session has expired. Please start a new one." } } } diff --git a/Sources/SignedShotSDK/CaptureService.swift b/Sources/SignedShotSDK/CaptureService.swift index 28edfb1..bebaba3 100644 --- a/Sources/SignedShotSDK/CaptureService.swift +++ b/Sources/SignedShotSDK/CaptureService.swift @@ -31,7 +31,7 @@ public enum CaptureError: Error, LocalizedError { case .cameraUnavailable: return "Camera is not available" case .permissionDenied: - return "Camera permission was denied" + return "Camera access is required. Please enable it in Settings > Privacy > Camera." case .captureInProgress: return "A capture is already in progress" case .captureFailed(let reason):