Some checks failed
CI / test (push) Has been cancelled
Move the app payload under swift/ while keeping git, package.json, and .smartconfig.json at the repo root. This standardizes the Swift app setup so build, test, run, and watch workflows match the other repos.
360 lines
11 KiB
Swift
360 lines
11 KiB
Swift
import Combine
|
|
import Foundation
|
|
|
|
@MainActor
|
|
final class AppViewModel: ObservableObject {
|
|
@Published var suggestedPairingPayload = ""
|
|
@Published var manualPairingPayload = ""
|
|
@Published var session: AuthSession?
|
|
@Published var profile: MemberProfile?
|
|
@Published var requests: [ApprovalRequest] = []
|
|
@Published var notifications: [AppNotification] = []
|
|
@Published var notificationPermission: NotificationPermissionState = .unknown
|
|
@Published var selectedSection: AppSection = .overview
|
|
@Published var isBootstrapping = false
|
|
@Published var isAuthenticating = false
|
|
@Published var isIdentifying = false
|
|
@Published var isRefreshing = false
|
|
@Published var isNotificationCenterPresented = false
|
|
@Published var activeRequestID: ApprovalRequest.ID?
|
|
@Published var isScannerPresented = false
|
|
@Published var errorMessage: String?
|
|
|
|
private var hasBootstrapped = false
|
|
private let service: IDPServicing
|
|
private let notificationCoordinator: NotificationCoordinating
|
|
private let appStateStore: AppStateStoring
|
|
private let launchArguments: [String]
|
|
|
|
private var preferredLaunchSection: AppSection? {
|
|
guard let argument = launchArguments.first(where: { $0.hasPrefix("--mock-section=") }) else {
|
|
return nil
|
|
}
|
|
|
|
let rawValue = String(argument.dropFirst("--mock-section=".count))
|
|
if rawValue == "notifications" {
|
|
return .activity
|
|
}
|
|
return AppSection(rawValue: rawValue)
|
|
}
|
|
|
|
init(
|
|
service: IDPServicing = MockIDPService(),
|
|
notificationCoordinator: NotificationCoordinating = NotificationCoordinator(),
|
|
appStateStore: AppStateStoring = UserDefaultsAppStateStore(),
|
|
launchArguments: [String] = ProcessInfo.processInfo.arguments
|
|
) {
|
|
self.service = service
|
|
self.notificationCoordinator = notificationCoordinator
|
|
self.appStateStore = appStateStore
|
|
self.launchArguments = launchArguments
|
|
}
|
|
|
|
var pendingRequests: [ApprovalRequest] {
|
|
requests
|
|
.filter { $0.status == .pending }
|
|
.sorted { $0.createdAt > $1.createdAt }
|
|
}
|
|
|
|
var handledRequests: [ApprovalRequest] {
|
|
requests
|
|
.filter { $0.status != .pending }
|
|
.sorted { $0.createdAt > $1.createdAt }
|
|
}
|
|
|
|
var unreadNotificationCount: Int {
|
|
notifications.filter(\.isUnread).count
|
|
}
|
|
|
|
var elevatedPendingCount: Int {
|
|
pendingRequests.filter { $0.risk == .elevated }.count
|
|
}
|
|
|
|
var latestNotification: AppNotification? {
|
|
notifications.first
|
|
}
|
|
|
|
var pairedDeviceSummary: String {
|
|
session?.deviceName ?? "No active device"
|
|
}
|
|
|
|
func bootstrap() async {
|
|
guard !hasBootstrapped else { return }
|
|
hasBootstrapped = true
|
|
|
|
restorePersistedState()
|
|
|
|
isBootstrapping = true
|
|
defer { isBootstrapping = false }
|
|
|
|
notificationPermission = await notificationCoordinator.authorizationStatus()
|
|
|
|
do {
|
|
let bootstrap = try await service.bootstrap()
|
|
suggestedPairingPayload = bootstrap.suggestedPairingPayload
|
|
manualPairingPayload = session?.pairingCode ?? bootstrap.suggestedPairingPayload
|
|
|
|
if launchArguments.contains("--mock-auto-pair"),
|
|
session == nil {
|
|
await signIn(with: bootstrap.suggestedPairingPayload, transport: .preview)
|
|
|
|
if let preferredLaunchSection {
|
|
selectedSection = preferredLaunchSection
|
|
}
|
|
}
|
|
} catch {
|
|
if session == nil {
|
|
errorMessage = "Unable to prepare the app."
|
|
}
|
|
}
|
|
}
|
|
|
|
func signInWithManualPayload() async {
|
|
await signIn(with: manualPairingPayload, transport: .manual)
|
|
}
|
|
|
|
func signInWithSuggestedPayload() async {
|
|
manualPairingPayload = suggestedPairingPayload
|
|
await signIn(with: suggestedPairingPayload, transport: .preview)
|
|
}
|
|
|
|
func signIn(
|
|
with payload: String,
|
|
transport: PairingTransport = .manual,
|
|
signedGPSPosition: SignedGPSPosition? = nil
|
|
) async {
|
|
await signIn(
|
|
with: PairingAuthenticationRequest(
|
|
pairingPayload: payload,
|
|
transport: transport,
|
|
signedGPSPosition: signedGPSPosition
|
|
)
|
|
)
|
|
}
|
|
|
|
func signIn(with request: PairingAuthenticationRequest) async {
|
|
let trimmed = request.pairingPayload.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else {
|
|
errorMessage = "Paste or scan a pairing payload first."
|
|
return
|
|
}
|
|
|
|
let normalizedRequest = PairingAuthenticationRequest(
|
|
pairingPayload: trimmed,
|
|
transport: request.transport,
|
|
signedGPSPosition: request.signedGPSPosition
|
|
)
|
|
|
|
isAuthenticating = true
|
|
defer { isAuthenticating = false }
|
|
|
|
do {
|
|
let result = try await service.signIn(with: normalizedRequest)
|
|
session = result.session
|
|
apply(snapshot: result.snapshot)
|
|
persistCurrentState()
|
|
notificationPermission = await notificationCoordinator.authorizationStatus()
|
|
selectedSection = .overview
|
|
errorMessage = nil
|
|
isScannerPresented = false
|
|
} catch let error as AppError {
|
|
errorMessage = error.errorDescription
|
|
} catch {
|
|
errorMessage = "Unable to complete sign-in."
|
|
}
|
|
}
|
|
|
|
func identifyWithNFC(_ request: PairingAuthenticationRequest) async {
|
|
guard session != nil else {
|
|
errorMessage = "Set up this passport before proving your identity with NFC."
|
|
return
|
|
}
|
|
|
|
await submitIdentityProof(
|
|
payload: request.pairingPayload,
|
|
transport: .nfc,
|
|
signedGPSPosition: request.signedGPSPosition
|
|
)
|
|
}
|
|
|
|
func identifyWithPayload(_ payload: String, transport: PairingTransport = .qr) async {
|
|
guard session != nil else {
|
|
errorMessage = "Set up this passport before proving your identity."
|
|
return
|
|
}
|
|
|
|
await submitIdentityProof(payload: payload, transport: transport)
|
|
}
|
|
|
|
private func submitIdentityProof(
|
|
payload: String,
|
|
transport: PairingTransport,
|
|
signedGPSPosition: SignedGPSPosition? = nil
|
|
) async {
|
|
let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else {
|
|
errorMessage = "The provided idp.global payload was empty."
|
|
return
|
|
}
|
|
|
|
let normalizedRequest = PairingAuthenticationRequest(
|
|
pairingPayload: trimmed,
|
|
transport: transport,
|
|
signedGPSPosition: signedGPSPosition
|
|
)
|
|
|
|
isIdentifying = true
|
|
defer { isIdentifying = false }
|
|
|
|
do {
|
|
let snapshot = try await service.identify(with: normalizedRequest)
|
|
apply(snapshot: snapshot)
|
|
persistCurrentState()
|
|
errorMessage = nil
|
|
isScannerPresented = false
|
|
} catch let error as AppError {
|
|
errorMessage = error.errorDescription
|
|
} catch {
|
|
errorMessage = "Unable to complete identity proof."
|
|
}
|
|
}
|
|
|
|
func refreshDashboard() async {
|
|
guard session != nil else { return }
|
|
|
|
isRefreshing = true
|
|
defer { isRefreshing = false }
|
|
|
|
do {
|
|
let snapshot = try await service.refreshDashboard()
|
|
apply(snapshot: snapshot)
|
|
persistCurrentState()
|
|
errorMessage = nil
|
|
} catch {
|
|
errorMessage = "Unable to refresh the dashboard."
|
|
}
|
|
}
|
|
|
|
func approve(_ request: ApprovalRequest) async {
|
|
await mutateRequest(request, approve: true)
|
|
}
|
|
|
|
func reject(_ request: ApprovalRequest) async {
|
|
await mutateRequest(request, approve: false)
|
|
}
|
|
|
|
func simulateIncomingRequest() async {
|
|
guard session != nil else { return }
|
|
|
|
do {
|
|
let snapshot = try await service.simulateIncomingRequest()
|
|
apply(snapshot: snapshot)
|
|
persistCurrentState()
|
|
selectedSection = .requests
|
|
errorMessage = nil
|
|
} catch {
|
|
errorMessage = "Unable to create a mock identity check right now."
|
|
}
|
|
}
|
|
|
|
func requestNotificationAccess() async {
|
|
do {
|
|
notificationPermission = try await notificationCoordinator.requestAuthorization()
|
|
errorMessage = nil
|
|
} catch {
|
|
errorMessage = "Unable to update notification permission."
|
|
}
|
|
}
|
|
|
|
func sendTestNotification() async {
|
|
do {
|
|
try await notificationCoordinator.scheduleTestNotification(
|
|
title: "idp.global identity proof requested",
|
|
body: "A mock identity proof request is waiting in the app."
|
|
)
|
|
notificationPermission = await notificationCoordinator.authorizationStatus()
|
|
errorMessage = nil
|
|
} catch {
|
|
errorMessage = "Unable to schedule a test notification."
|
|
}
|
|
}
|
|
|
|
func markNotificationRead(_ notification: AppNotification) async {
|
|
do {
|
|
let snapshot = try await service.markNotificationRead(id: notification.id)
|
|
apply(snapshot: snapshot)
|
|
persistCurrentState()
|
|
errorMessage = nil
|
|
} catch {
|
|
errorMessage = "Unable to update the notification."
|
|
}
|
|
}
|
|
|
|
func signOut() {
|
|
appStateStore.clear()
|
|
session = nil
|
|
profile = nil
|
|
requests = []
|
|
notifications = []
|
|
selectedSection = .overview
|
|
manualPairingPayload = suggestedPairingPayload
|
|
errorMessage = nil
|
|
}
|
|
|
|
private func mutateRequest(_ request: ApprovalRequest, approve: Bool) async {
|
|
guard session != nil else { return }
|
|
|
|
activeRequestID = request.id
|
|
defer { activeRequestID = nil }
|
|
|
|
do {
|
|
let snapshot = approve
|
|
? try await service.approveRequest(id: request.id)
|
|
: try await service.rejectRequest(id: request.id)
|
|
apply(snapshot: snapshot)
|
|
persistCurrentState()
|
|
errorMessage = nil
|
|
} catch {
|
|
errorMessage = "Unable to update the identity check."
|
|
}
|
|
}
|
|
|
|
private func restorePersistedState() {
|
|
guard let state = appStateStore.load() else {
|
|
return
|
|
}
|
|
|
|
session = state.session
|
|
manualPairingPayload = state.session.pairingCode
|
|
apply(
|
|
snapshot: DashboardSnapshot(
|
|
profile: state.profile,
|
|
requests: state.requests,
|
|
notifications: state.notifications
|
|
)
|
|
)
|
|
}
|
|
|
|
private func persistCurrentState() {
|
|
guard let session, let profile else {
|
|
appStateStore.clear()
|
|
return
|
|
}
|
|
|
|
appStateStore.save(
|
|
PersistedAppState(
|
|
session: session,
|
|
profile: profile,
|
|
requests: requests,
|
|
notifications: notifications
|
|
)
|
|
)
|
|
}
|
|
|
|
private func apply(snapshot: DashboardSnapshot) {
|
|
profile = snapshot.profile
|
|
requests = snapshot.requests.sorted { $0.createdAt > $1.createdAt }
|
|
notifications = snapshot.notifications.sorted { $0.sentAt > $1.sentAt }
|
|
}
|
|
}
|