field_ios

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

commit 6dd31fc2461c0a5c0294c9083167ead645882b4f
parent 86efc576d4ce8fca722147fad69a354e9338e328
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 14:53:14 -0700

external-actions: add runtime boundary

Diffstat:
MRadroots.xcodeproj/project.pbxproj | 4++++
MRadroots/App/AppState.swift | 47+++++++++++++++++++++++++++++++++++++++++++++++
MRadroots/Info.plist | 4++++
ARadroots/Runtime/FieldExternalActions.swift | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 171 insertions(+), 0 deletions(-)

diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj @@ -32,6 +32,7 @@ 7C8DD424F3E3E0AB1B133863 /* RadrootsKitBindings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC881872F750120A184F45E6 /* RadrootsKitBindings.swift */; }; 7E650F6DA30931E310F842E2 /* FieldFileAccessUITestProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491D57E540BAB04F24619737 /* FieldFileAccessUITestProbe.swift */; }; 7FD8FB018DA09568303194B2 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE61264E2C98E73828E8680 /* Strings.swift */; }; + 82903551F5E15FBDAC388D20 /* FieldExternalActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E6187FA7C4786EC662718B2 /* FieldExternalActions.swift */; }; 8B923F78BF5B680C7F6A7CE3 /* PostFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE0EB327C10171444553378 /* PostFeedView.swift */; }; 8F6D0970610DF68816DE1A98 /* Radroots.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F0F21496E7A8490EB14AC5B /* Radroots.swift */; }; 9121BD4A3E7C6EF2B21F540F /* CopyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4C7DE4207398DE242519F9C /* CopyButton.swift */; }; @@ -69,6 +70,7 @@ 2818363B157125491FB84A1E /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = "<group>"; }; 2F3541389124A31F5D701A45 /* FieldLocationCheckIn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldLocationCheckIn.swift; sourceTree = "<group>"; }; 2FE790CA1CD31208947913B9 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; }; + 3E6187FA7C4786EC662718B2 /* FieldExternalActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldExternalActions.swift; sourceTree = "<group>"; }; 41A4289F43625DD65E6C4B25 /* CopyRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyRow.swift; sourceTree = "<group>"; }; 466BFA2F60BE3113EDD1BA3B /* TradeOrderRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TradeOrderRequestView.swift; sourceTree = "<group>"; }; 491D57E540BAB04F24619737 /* FieldFileAccessUITestProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldFileAccessUITestProbe.swift; sourceTree = "<group>"; }; @@ -191,6 +193,7 @@ 7CC1D11E2E296B4B54E7E8A9 /* FieldCaptureIntake.swift */, E4F2BDC19EF9435162BFF5EC /* FieldDocumentInterchange.swift */, EF05B944D348DAA4EF28B463 /* FieldDocumentInterchangeUITestProbe.swift */, + 3E6187FA7C4786EC662718B2 /* FieldExternalActions.swift */, 491D57E540BAB04F24619737 /* FieldFileAccessUITestProbe.swift */, CA942715DB13FFD494FD35A0 /* FieldIdentityPublicMetadataStore.swift */, 9EB0A2CFCBBFA9D204C6992B /* FieldLocalState.swift */, @@ -434,6 +437,7 @@ 299A6111507F657670856F36 /* FieldCaptureIntake.swift in Sources */, 3FC570AC038C3DC575E5A3E7 /* FieldDocumentInterchange.swift in Sources */, E432FD39ECC8F03764EEED81 /* FieldDocumentInterchangeUITestProbe.swift in Sources */, + 82903551F5E15FBDAC388D20 /* FieldExternalActions.swift in Sources */, 7E650F6DA30931E310F842E2 /* FieldFileAccessUITestProbe.swift in Sources */, D4CFDE54747B6D6957977025 /* FieldIdentityPublicMetadataStore.swift in Sources */, 6E15D30653861F26AC45B501 /* FieldLocalState.swift in Sources */, diff --git a/Radroots/App/AppState.swift b/Radroots/App/AppState.swift @@ -43,6 +43,8 @@ public final class AppState: ObservableObject { @Published public private(set) var relayLastError: String? @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 canOpenNostrProfile: Bool = false @Published public private(set) var locationCheckInState: FieldLocationCheckInState = .idle( RadrootsLocationServicesAvailability(locationServicesEnabled: false, authorization: .unavailable) ) @@ -79,6 +81,7 @@ public final class AppState: ObservableObject { private var identityMetadataStore: FieldIdentityPublicMetadataStore? private var captureIntake: FieldCaptureIntake? private let locationCheckIn = FieldLocationCheckIn.configured() + private let externalActions = FieldExternalActions.configured() public init(radroots: Radroots = Radroots()) { self.radroots = radroots @@ -125,6 +128,7 @@ public final class AppState: ObservableObject { try await connect(using: service) startPollingStatus() } + await refreshNostrProfileExternalActionCapability() try refreshFileAccessProbe( bundleIdentifier: appBundleIdentifier, resetLocalStateRequested: resetLocalStateRequested, @@ -162,6 +166,7 @@ public final class AppState: ObservableObject { setLocked(false) try await connect(using: service) await refreshRuntimeState(using: service) + await refreshNostrProfileExternalActionCapability() startPollingStatus() } @@ -171,6 +176,7 @@ public final class AppState: ObservableObject { setLocked(false) try await connect(using: service) await refreshRuntimeState(using: service) + await refreshNostrProfileExternalActionCapability() startPollingStatus() } @@ -187,6 +193,7 @@ public final class AppState: ObservableObject { setLocked(false) try await connect(using: service) await refreshRuntimeState(using: service) + await refreshNostrProfileExternalActionCapability() startPollingStatus() } @@ -213,6 +220,8 @@ public final class AppState: ObservableObject { relayConnectingCount = 0 relayLight = .red relayLastError = nil + canOpenNostrProfile = false + externalActionStatus = nil await refreshRuntimeState(using: service) try refreshFileAccessProbe( bundleIdentifier: try bundleIdentifier(), @@ -268,6 +277,31 @@ public final class AppState: ObservableObject { } } + public func refreshNostrProfileExternalActionCapability() async { + guard let npub else { + canOpenNostrProfile = false + return + } + canOpenNostrProfile = await externalActions.canOpenPublicNostrProfile(npub: npub) + } + + public func openAppSettingsRecovery() async { + await requestExternalAction { + try await externalActions.openAppSettings() + } + } + + public func openCurrentNostrProfile() async { + guard let npub else { + externalActionStatus = "No public Nostr identity is selected." + canOpenNostrProfile = false + return + } + await requestExternalAction { + try await externalActions.openPublicNostrProfile(npub: npub) + } + } + func prepareDiagnosticsDocumentExport() throws -> RadrootsPreparedExportDocument { try documentInterchange().prepareDiagnosticsExport( infoJSONString: infoJSONString, @@ -438,6 +472,7 @@ public final class AppState: ObservableObject { hasKey = false npub = nil identityLabel = nil + canOpenNostrProfile = false } } @@ -519,6 +554,7 @@ public final class AppState: ObservableObject { npub = nil identityLabel = nil identities = [] + canOpenNostrProfile = false } private func secureIdentityStoreOrConfigured() throws -> FieldSecureIdentityStore { @@ -580,6 +616,17 @@ public final class AppState: ObservableObject { ) } + private func requestExternalAction( + _ action: () async throws -> FieldExternalActionRequestRecord + ) async { + do { + let record = try await action() + externalActionStatus = record.statusText + } catch { + externalActionStatus = error.localizedDescription + } + } + private func setLocked(_ value: Bool) { isLocked = value UserDefaults.standard.set(value, forKey: lockKey) diff --git a/Radroots/Info.plist b/Radroots/Info.plist @@ -24,6 +24,10 @@ <string>Radroots uses your location only when you tap Location Check-in.</string> <key>NSCameraUsageDescription</key> <string>Radroots uses the camera only when you capture photo evidence or scan a document.</string> + <key>LSApplicationQueriesSchemes</key> + <array> + <string>nostr</string> + </array> <key>GIT_SHA</key> <string>$(GIT_SHA)</string> diff --git a/Radroots/Runtime/FieldExternalActions.swift b/Radroots/Runtime/FieldExternalActions.swift @@ -0,0 +1,116 @@ +import Foundation +import RadrootsKit +import RadrootsKitTesting + +public struct FieldExternalActionRequestRecord: Equatable, Sendable { + public let kind: RadrootsExternalActionDestinationKind + public let urlString: String? + + init(destination: RadrootsExternalActionDestination) { + self.kind = destination.kind + self.urlString = destination.url?.absoluteString + } + + public var statusText: String { + switch kind { + case .appSettings: + "Requested app settings" + case .web: + "Requested web link" + case .nostr: + "Requested Nostr profile" + case .appleMaps: + "Requested Apple Maps" + } + } +} + +final class FieldExternalActions: Sendable { + private let actions: any RadrootsExternalActions + + init(actions: any RadrootsExternalActions) { + self.actions = actions + } + + static func configured() -> FieldExternalActions { + guard uiTestWasRequested else { + return FieldExternalActions(actions: RadrootsAppleExternalActions()) + } + return FieldExternalActions(actions: uiTestExternalActions()) + } + + func canOpenPublicNostrProfile(npub: String) async -> Bool { + guard let destination = try? publicNostrProfileDestination(npub: npub) else { + return false + } + return await actions.canOpen(destination).canOpen + } + + func openAppSettings() async throws -> FieldExternalActionRequestRecord { + let destination = RadrootsExternalActionDestination.appSettings + try await actions.open(RadrootsExternalActionRequest(destination: destination)) + return FieldExternalActionRequestRecord(destination: destination) + } + + func openPublicNostrProfile(npub: String) async throws -> FieldExternalActionRequestRecord { + let destination = try publicNostrProfileDestination(npub: npub) + try await actions.open(RadrootsExternalActionRequest(destination: destination)) + return FieldExternalActionRequestRecord(destination: destination) + } + + private func publicNostrProfileDestination(npub: String) throws -> RadrootsExternalActionDestination { + try RadrootsExternalActionDestination.nostr("nostr:\(npub)") + } + + private static var uiTestWasRequested: Bool { + let arguments = ProcessInfo.processInfo.arguments + let environment = ProcessInfo.processInfo.environment + return environment["RADROOTS_FIELD_IOS_UI_TEST"] == "true" || + arguments.contains("--radroots-field-ios-ui-test") + } + + private static func uiTestExternalActions() -> RadrootsFakeExternalActions { + RadrootsFakeExternalActions( + defaultCanOpen: uiTestCanOpen, + openOutcome: uiTestOpenOutcome + ) + } + + private static var uiTestCanOpen: Bool { + let environment = ProcessInfo.processInfo.environment + if let raw = environment["RADROOTS_FIELD_IOS_UI_TEST_EXTERNAL_ACTIONS_NOSTR_CAN_OPEN"] { + return parseBool(raw) ?? true + } + if let raw = environment["RADROOTS_FIELD_IOS_UI_TEST_EXTERNAL_ACTIONS_CAN_OPEN"] { + return parseBool(raw) ?? true + } + return true + } + + private static var uiTestOpenOutcome: Result<Void, RadrootsExternalActionError> { + let raw = ProcessInfo.processInfo.environment["RADROOTS_FIELD_IOS_UI_TEST_EXTERNAL_ACTIONS_OPEN_OUTCOME"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + switch raw { + case nil, "", "success": + return .success(()) + case "unavailable": + return .failure(.unavailable("external actions are unavailable in this UI test")) + case "transient_failure": + return .failure(.transientFailure("external action failed in this UI test")) + default: + return .failure(.blockedByPolicy("unsupported UI-test external action outcome")) + } + } + + private static func parseBool(_ raw: String) -> Bool? { + switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "1", "true", "yes": + true + case "0", "false", "no": + false + default: + nil + } + } +}