field_ios

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

commit 7b6cb20ac3898f570961c9370a4665cb8a6556f3
parent 417d5882b824fd3e8e0ec9cd74a66a9b8da4d226
Author: triesap <tyson@radroots.org>
Date:   Mon, 15 Jun 2026 14:44:43 -0700

telemetry: add deterministic UI test probe

- expose recorded UI-test telemetry through a hidden safe probe

- route document interchange probes through app telemetry wrappers

- publish structured event coverage and unsafe-value absence flags

- keep the telemetry proof out of product-facing UI

Diffstat:
MRadroots.xcodeproj/project.pbxproj | 4++++
MRadroots/App/AppEntry.swift | 7+++++++
MRadroots/App/AppState.swift | 37+++++++++++++++++++++++++++++++++++++
MRadroots/Runtime/FieldDocumentInterchangeUITestProbe.swift | 18++++++++++++++++++
ARadroots/Runtime/FieldTelemetryUITestProbe.swift | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 156 insertions(+), 0 deletions(-)

diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj @@ -42,6 +42,7 @@ B971351ABE8E79A472B4DC7D /* View+Nav.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0B0C9861CD86EAD3CAD549E /* View+Nav.swift */; }; C22DB0F3EB2E69A34DF941E0 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2818363B157125491FB84A1E /* App.swift */; }; C8AB3389F7430A5C79AD7DF8 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D12A016D1377CDFBFB0F9B /* SettingsView.swift */; }; + CC5561169A29B5B2B6423959 /* FieldTelemetryUITestProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E6A1827AB4419F806CD848F /* FieldTelemetryUITestProbe.swift */; }; D25C1E1DC99F5CF8E99AE970 /* FieldRuntimeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E883FDB7004A210C9D7BE27A /* FieldRuntimeService.swift */; }; D3834AF9A4E1327B7DA557F3 /* CopyRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A4289F43625DD65E6C4B25 /* CopyRow.swift */; }; D4CFDE54747B6D6957977025 /* FieldIdentityPublicMetadataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA942715DB13FFD494FD35A0 /* FieldIdentityPublicMetadataStore.swift */; }; @@ -85,6 +86,7 @@ 7CC1D11E2E296B4B54E7E8A9 /* FieldCaptureIntake.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldCaptureIntake.swift; sourceTree = "<group>"; }; 7D69D200DB1F5FA7AA561CD7 /* TradeListing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TradeListing.swift; sourceTree = "<group>"; }; 8246B707FA9D218414EC4038 /* FieldSecureIdentityStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldSecureIdentityStore.swift; sourceTree = "<group>"; }; + 8E6A1827AB4419F806CD848F /* FieldTelemetryUITestProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldTelemetryUITestProbe.swift; sourceTree = "<group>"; }; 8F0F21496E7A8490EB14AC5B /* Radroots.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Radroots.swift; sourceTree = "<group>"; }; 93AA285819DD1269C3EAD80A /* Radroots.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Radroots.app; sourceTree = BUILT_PRODUCTS_DIR; }; 93D729E070C32490545FA837 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; }; @@ -201,6 +203,7 @@ E883FDB7004A210C9D7BE27A /* FieldRuntimeService.swift */, 8246B707FA9D218414EC4038 /* FieldSecureIdentityStore.swift */, 3B4E53FD4C4AADF63855888A /* FieldTelemetry.swift */, + 8E6A1827AB4419F806CD848F /* FieldTelemetryUITestProbe.swift */, D65835E1C633C7B946C64D11 /* FieldUserPresenceGate.swift */, D4DE3DD8C3BB2F63676F463E /* LoggingSettings.swift */, 63189EB90A86A9929BECD9ED /* Nostr.swift */, @@ -436,6 +439,7 @@ D25C1E1DC99F5CF8E99AE970 /* FieldRuntimeService.swift in Sources */, D9BF5BE7E4AB5EACBF342539 /* FieldSecureIdentityStore.swift in Sources */, 9346DA48630668A65D37E14A /* FieldTelemetry.swift in Sources */, + CC5561169A29B5B2B6423959 /* FieldTelemetryUITestProbe.swift in Sources */, 25654E50F9519809A237759D /* FieldUserPresenceGate.swift in Sources */, 1E5B41A3E1F9A7D68F63B079 /* HomeView.swift in Sources */, B8A3BBDE3A1FC0248512BF76 /* LoggingSettings.swift in Sources */, diff --git a/Radroots/App/AppEntry.swift b/Radroots/App/AppEntry.swift @@ -43,6 +43,13 @@ public struct AppEntry<Main: View>: View { .accessibilityIdentifier("field_ios.document_interchange.probe") .accessibilityValue(probeValue) } + if let probeValue = appState.telemetryProbeValue { + Color.clear + .frame(width: 1, height: 1) + .accessibilityElement() + .accessibilityIdentifier("field_ios.telemetry.probe") + .accessibilityValue(probeValue) + } } } } diff --git a/Radroots/App/AppState.swift b/Radroots/App/AppState.swift @@ -43,6 +43,7 @@ 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 telemetryProbeValue: String? @Published public private(set) var externalActionStatus: String? @Published public private(set) var userPresenceStatus: String? @Published public private(set) var canOpenNostrProfile: Bool = false @@ -79,6 +80,7 @@ public final class AppState: ObservableObject { private let lockKey = "field_ios.identity_locked" private var statusTask: Task<Void, Never>? + private var telemetryProbeTask: Task<Void, Never>? private var secureIdentityStore: FieldSecureIdentityStore? private var identityMetadataStore: FieldIdentityPublicMetadataStore? private var captureIntake: FieldCaptureIntake? @@ -95,6 +97,7 @@ public final class AppState: ObservableObject { deinit { statusTask?.cancel() + telemetryProbeTask?.cancel() } public func start() async throws { @@ -148,12 +151,16 @@ public final class AppState: ObservableObject { runtimeIdentityReady: runtimeIdentityReady, locked: isLocked ) + startTelemetryProbeRefreshForUITest() } catch { statusTask?.cancel() statusTask = nil + telemetryProbeTask?.cancel() + telemetryProbeTask = nil let message = error.localizedDescription bootstrapPhase = .failed(message) telemetry.appStartupFailed(error) + startTelemetryProbeRefreshForUITest() throw error } } @@ -772,6 +779,19 @@ public final class AppState: ObservableObject { connectingCount: relayConnectingCount, lastError: relayLastError ) + guard FieldDocumentInterchangeUITestProbe.isRequested else { + return + } + let diagnosticsExport = try prepareDiagnosticsDocumentExport() + releasePreparedDocumentExport(diagnosticsExport) + let relayConfigExport = try prepareRelayConfigDocumentExport() + releasePreparedDocumentExport(relayConfigExport) + if let relayImportDocument = try FieldDocumentInterchangeUITestProbe.relayImportDocument( + bundleIdentifier: bundleIdentifier + ) { + _ = try importedRelayConfig(from: relayImportDocument) + } + _ = try publicPostShareRequest(content: " public field update ") } private func requestExternalAction( @@ -812,6 +832,23 @@ public final class AppState: ObservableObject { } } + private func startTelemetryProbeRefreshForUITest() { + guard FieldTelemetryUITestProbe.isRequested else { + return + } + telemetryProbeTask?.cancel() + telemetryProbeTask = Task { [weak self] in + while !Task.isCancelled { + await self?.refreshTelemetryProbeValue() + try? await Task.sleep(nanoseconds: 250_000_000) + } + } + } + + private func refreshTelemetryProbeValue() async { + telemetryProbeValue = await FieldTelemetryUITestProbe.value(recordedBy: telemetry) + } + private func shortNpub(_ value: String) -> String { guard value.count > 18 else { return value } return "\(value.prefix(12))...\(value.suffix(6))" diff --git a/Radroots/Runtime/FieldDocumentInterchangeUITestProbe.swift b/Radroots/Runtime/FieldDocumentInterchangeUITestProbe.swift @@ -16,6 +16,24 @@ enum FieldDocumentInterchangeUITestProbe { ProcessInfo.processInfo.environment[enabledKey] == "true" } + static func relayImportDocument(bundleIdentifier: String) throws -> RadrootsImportedDocument? { + guard isRequested else { + return nil + } + let data = relayImportFixtureData() + try FieldLocalState.fileAccess(bundleIdentifier: bundleIdentifier).write( + .inline(data), + to: importFixture + ) + return try RadrootsImportedDocument( + file: importFixture, + originalURL: nil, + suggestedFilename: "radroots-relays.json", + mediaType: "application/json", + sizeBytes: UInt64(data.count) + ) + } + static func startupValue( bundleIdentifier: String, infoJSONString: String, diff --git a/Radroots/Runtime/FieldTelemetryUITestProbe.swift b/Radroots/Runtime/FieldTelemetryUITestProbe.swift @@ -0,0 +1,90 @@ +import Foundation +import RadrootsKit + +enum FieldTelemetryUITestProbe { + private static let enabledKey = "RADROOTS_FIELD_IOS_UI_TEST_TELEMETRY_PROBE" + private static let redactionPolicy = RadrootsTelemetryRedactionPolicy.default + + static var isRequested: Bool { + ProcessInfo.processInfo.environment[enabledKey] == "true" + } + + static func value(recordedBy telemetry: FieldTelemetry) async -> String? { + guard isRequested else { + return nil + } + let events = await telemetry.recordedEventsForUITest() + return value(events: events) + } + + private static func value(events: [RadrootsTelemetryEvent]) -> String { + let eventNames = Set(events.map(\.name)) + let fieldKeys = Set(events.flatMap { event in + event.fields.map(\.key) + }) + let values = events.flatMap(stringValues) + return [ + "event_count=\(events.count)", + "event_names=\(eventNames.sorted().joined(separator: ","))", + "field_keys=\(fieldKeys.sorted().joined(separator: ","))", + "runtime_logging_seen=\(eventNames.contains("field_ios.runtime.logging_initialized"))", + "startup_success_seen=\(eventNames.contains("field_ios.startup.success"))", + "relay_status_seen=\(eventNames.contains("field_ios.relay.status_changed"))", + "identity_create_seen=\(eventNames.contains("field_ios.identity_custody.create"))", + "user_presence_save_identity_seen=\(eventNames.contains("field_ios.user_presence.save_identity"))", + "document_diagnostics_export_seen=\(eventNames.contains("field_ios.document_interchange.diagnostics_export"))", + "document_relay_config_export_seen=\(eventNames.contains("field_ios.document_interchange.relay_config_export"))", + "document_relay_config_import_seen=\(eventNames.contains("field_ios.document_interchange.relay_config_import"))", + "document_public_share_prepare_seen=\(eventNames.contains("field_ios.document_interchange.public_share_prepare"))", + "capture_support_refreshed_seen=\(eventNames.contains("field_ios.capture.support_refreshed"))", + "capture_import_photo_seen=\(eventNames.contains("field_ios.capture.import_photo"))", + "capture_scan_document_seen=\(eventNames.contains("field_ios.capture.scan_document"))", + "external_action_open_seen=\(eventNames.contains("field_ios.external_action.open"))", + "unsafe_values_present=\(values.contains(where: containsUnsafeValue))", + "relay_url_values_present=\(values.contains(where: containsRelayURL))", + "secret_like_values_present=\(values.contains(where: containsSecretLikeValue))", + "path_like_values_present=\(values.contains(where: containsPathLikeValue))", + "npub_values_present=\(values.contains(where: containsNpubValue))" + ].joined(separator: ";") + } + + private static func stringValues(from event: RadrootsTelemetryEvent) -> [String] { + var values = event.message.map { [$0] } ?? [] + values.append(contentsOf: event.fields.map { field in + field.value.renderedValue + }) + return values + } + + private static func containsUnsafeValue(_ value: String) -> Bool { + redactionPolicy.containsUnsafeValue(value) + || containsRelayURL(value) + || containsSecretLikeValue(value) + || containsPathLikeValue(value) + || containsNpubValue(value) + } + + private static func containsRelayURL(_ value: String) -> Bool { + let normalized = value.lowercased() + return normalized.contains("ws://") || normalized.contains("wss://") + } + + private static func containsSecretLikeValue(_ value: String) -> Bool { + let normalized = value.lowercased() + return normalized.contains("nsec") + || normalized.range(of: "[a-f0-9]{64}", options: .regularExpression) != nil + } + + private static func containsPathLikeValue(_ value: String) -> Bool { + let normalized = value.lowercased() + return normalized.contains("/users/") + || normalized.contains("/private/var/") + || normalized.contains("/var/mobile/containers/") + || normalized.contains("/var/folders/") + || normalized.contains("file:///") + } + + private static func containsNpubValue(_ value: String) -> Bool { + value.lowercased().contains("npub") + } +}