field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | LICENSE

commit 3353c52c82cff9c50c6a44fdfc01f3131d107130
parent 06c1dc402ec42601719422a9f07d7c8093cfee52
Author: triesap <tyson@radroots.org>
Date:   Mon, 15 Jun 2026 13:25:14 -0700

user-presence: gate identity actions

Require verified user presence before unlocking, saving, importing, or deleting local Nostr identity material.
Expose a small setup/settings status surface so deterministic UI tests can prove the requested gate outcome.

Diffstat:
MRadroots/App/AppState.swift | 16++++++++++++++++
MRadroots/Views/SettingsView.swift | 7+++++++
MRadroots/Views/SetupView.swift | 20++++++++++++++++++++
3 files changed, 43 insertions(+), 0 deletions(-)

diff --git a/Radroots/App/AppState.swift b/Radroots/App/AppState.swift @@ -44,6 +44,7 @@ public final class AppState: ObservableObject { @Published public private(set) var fileAccessProbeValue: String? @Published public private(set) var documentInterchangeProbeValue: String? @Published public private(set) var externalActionStatus: String? + @Published public private(set) var userPresenceStatus: String? @Published public private(set) var canOpenNostrProfile: Bool = false @Published public private(set) var locationCheckInState: FieldLocationCheckInState = .idle( RadrootsLocationServicesAvailability(locationServicesEnabled: false, authorization: .unavailable) @@ -82,6 +83,7 @@ public final class AppState: ObservableObject { private var captureIntake: FieldCaptureIntake? private let locationCheckIn = FieldLocationCheckIn.configured() private let externalActions = FieldExternalActions.configured() + private let userPresenceGate = FieldUserPresenceGate.configured() public init(radroots: Radroots = Radroots()) { self.radroots = radroots @@ -161,6 +163,7 @@ public final class AppState: ObservableObject { public func continueWithLocalIdentity() async throws { let service = try requireRuntimeService() + try await requireUserPresence(for: .unlockIdentity) try await restoreStoredIdentity(using: service) setLocked(false) await refreshRuntimeState(using: service) @@ -170,6 +173,7 @@ public final class AppState: ObservableObject { public func createLocalIdentity() async throws { let service = try requireRuntimeService() + try await requireUserPresence(for: .saveIdentity) try await createHostCustodyIdentity(using: service) setLocked(false) await refreshRuntimeState(using: service) @@ -181,6 +185,7 @@ public final class AppState: ObservableObject { let trimmed = secretKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } let service = try requireRuntimeService() + try await requireUserPresence(for: .saveIdentity) let record = try await secureIdentityStoreOrConfigured().importSecret( trimmed, label: "Imported Field Identity", @@ -207,6 +212,7 @@ public final class AppState: ObservableObject { public func resetLocalIdentity() async throws { let service = try requireRuntimeService() + try await requireUserPresence(for: .deleteIdentity) try secureIdentityStoreOrConfigured().deleteSelectedSecret() try identityMetadataStoreOrConfigured().delete() try await resetRuntimeIdentityState(using: service) @@ -533,6 +539,16 @@ public final class AppState: ObservableObject { try persistIdentity(record) } + private func requireUserPresence(for action: FieldUserPresenceAction) async throws { + do { + let record = try await userPresenceGate.requirePresence(for: action) + userPresenceStatus = record.statusText + } catch { + userPresenceStatus = error.localizedDescription + throw error + } + } + private func createHostCustodyIdentity(using service: FieldRuntimeService) async throws { let record = try await secureIdentityStoreOrConfigured().createIdentity( label: "Radroots Field", diff --git a/Radroots/Views/SettingsView.swift b/Radroots/Views/SettingsView.swift @@ -47,6 +47,12 @@ struct SettingsView: View { value: app.runtimeIdentityReady ? "Unlocked" : "Locked", identifier: "field_ios.settings.runtime_identity" ) + if let userPresenceStatus = app.userPresenceStatus { + Text(userPresenceStatus) + .font(.footnote) + .foregroundStyle(.secondary) + .accessibilityIdentifier("field_ios.user_presence.status") + } NavigationLink { ProfileView() @@ -101,6 +107,7 @@ struct SettingsView: View { if let resetError { Text(resetError) .foregroundStyle(.red) + .accessibilityIdentifier("field_ios.settings.reset_error") } } } diff --git a/Radroots/Views/SetupView.swift b/Radroots/Views/SetupView.swift @@ -41,6 +41,7 @@ struct SetupView: View { } SetupErrorText(errorMessage) + UserPresenceStatusText(app.userPresenceStatus) if isWorking { ProgressView() @@ -186,3 +187,22 @@ private struct SetupErrorText: View { } } } + +private struct UserPresenceStatusText: View { + let message: String? + + init(_ message: String?) { + self.message = message + } + + var body: some View { + if let message { + Text(message) + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + .accessibilityIdentifier("field_ios.user_presence.status") + } + } +}