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"> Void)? @State private var savedPhotoURL: URL? @State private var isSetup = false @@ -39,6 +41,10 @@ struct ContentView: View { private let storage = PhotoStorage() private let client: SignedShotClient + // Session expired state + @State private var sessionExpired = false + @State private var sessionTimeRemaining: Int = 0 + // Secure Enclave state @State private var isEnclaveReady = false @@ -120,8 +126,31 @@ struct ContentView: View { .task { await initialize() } + .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { _ in + if let session = currentSession { + let remaining = Int(session.expiresAt.timeIntervalSinceNow) + sessionTimeRemaining = max(remaining, 0) + if remaining <= 0 { + sessionExpired = true + currentSession = nil + } + } + } .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 ?? "") } @@ -263,14 +292,29 @@ struct ContentView: View { private var sessionPrompt: some View { VStack(spacing: 12) { - Text("Ready to Capture") - .font(.headline) - .foregroundColor(.white) + if sessionExpired { + Image(systemName: "clock.badge.exclamationmark") + .foregroundColor(.orange) + .font(.title2) - Text("Start a capture session to take an authenticated photo") - .font(.caption) - .foregroundColor(.gray) - .multilineTextAlignment(.center) + Text("Session Expired") + .font(.headline) + .foregroundColor(.white) + + Text("Your capture session has expired. Create a new one to continue.") + .font(.caption) + .foregroundColor(.gray) + .multilineTextAlignment(.center) + } else { + Text("Ready to Capture") + .font(.headline) + .foregroundColor(.white) + + Text("Start a capture session to take an authenticated photo") + .font(.caption) + .foregroundColor(.gray) + .multilineTextAlignment(.center) + } Button(action: { Task { await startSession() } @@ -282,12 +326,12 @@ struct ContentView: View { } else { Image(systemName: "play.circle.fill") } - Text(isStartingSession ? "Starting..." : "Start Session") + Text(isStartingSession ? "Starting..." : sessionExpired ? "Create New Session" : "Start Session") } .foregroundColor(.white) .padding(.horizontal, 20) .padding(.vertical, 10) - .background(.green) + .background(sessionExpired ? .orange : .green) .cornerRadius(8) } .disabled(isStartingSession) @@ -310,11 +354,10 @@ struct ContentView: View { .font(.caption2) .foregroundColor(.gray) - let remaining = session.expiresAt.timeIntervalSinceNow - if remaining > 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) @@ -475,9 +518,21 @@ struct ContentView: View { deviceId = response.deviceId } catch { errorMessage = error.localizedDescription + retryAction = { await registerDevice() } } } + 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() @@ -511,8 +566,12 @@ struct ContentView: View { do { let session = try await client.createCaptureSession() currentSession = session + sessionExpired = false + } catch SignedShotAPIError.unauthorized { + await handleUnauthorized() } catch { errorMessage = error.localizedDescription + retryAction = { await startSession() } } } @@ -559,9 +618,19 @@ 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 SignedShotAPIError.unauthorized { + isExchangingToken = false + lastCapturedPhoto = nil + await handleUnauthorized() } catch { isExchangingToken = false errorMessage = error.localizedDescription + retryAction = { await capturePhoto() } } } 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):