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 @@ -31,7 +31,7 @@
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Release"
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
Expand Down
97 changes: 83 additions & 14 deletions ExampleApp/ExampleApp/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// ExampleApp
//

import Combine
import FirebaseAppCheck
import os.log
import SignedShotSDK
Expand All @@ -15,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

Expand All @@ -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

Expand Down Expand Up @@ -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 ?? "")
}
Expand Down Expand Up @@ -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() }
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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() }
}
}

Expand Down Expand Up @@ -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() }
}
}

Expand Down
31 changes: 17 additions & 14 deletions Sources/SignedShotSDK/APIModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/SignedShotSDK/CaptureService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down