field_ios

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

commit 2b739836097054dda56a8aae85c4298750faea16
parent 3d5215d146a05fff52cb1f6c238de1741fe84b6f
Author: triesap <tyson@radroots.org>
Date:   Wed, 17 Jun 2026 18:05:39 -0700

field-ios: add background execution UI test probe

- expose a hidden deterministic background execution probe
- seed fake transfers and expired staged blobs for maintenance checks
- report redacted scheduling and cancellation probe state
- keep the probe behind field iOS UI-test launch configuration

Diffstat:
MRadroots.xcodeproj/project.pbxproj | 4++++
MRadroots/App/AppEntry.swift | 7+++++++
MRadroots/App/AppState.swift | 6++++++
MRadroots/Runtime/FieldBackgroundExecution.swift | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ARadroots/Runtime/FieldBackgroundExecutionUITestProbe.swift | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 214 insertions(+), 0 deletions(-)

diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 299A6111507F657670856F36 /* FieldCaptureIntake.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CC1D11E2E296B4B54E7E8A9 /* FieldCaptureIntake.swift */; }; 2B3886FD26434A54F3726591 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 8D19AA8D515FC8F5D2407378 /* Localizable.strings */; }; 2B6ACA26689B355CECBFFB57 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16A7641E5C643B4B36CFEDA8 /* SetupView.swift */; }; + 2FAE0FC43EB547F2CE7A567D /* FieldBackgroundExecutionUITestProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59AC0543EF8335D691D56BD3 /* FieldBackgroundExecutionUITestProbe.swift */; }; 33A800AA701C354099623B24 /* MarketView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08C0B870E44C7B152A7FABE0 /* MarketView.swift */; }; 35D8223F5E169DDB4E3E87C0 /* TradeSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6D224B525028DE5D8C8E28D /* TradeSettings.swift */; }; 360F23EFE80FDBDC6983FB15 /* AppEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D448C9655B708CA3FA8712B9 /* AppEntry.swift */; }; @@ -78,6 +79,7 @@ 491D57E540BAB04F24619737 /* FieldFileAccessUITestProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldFileAccessUITestProbe.swift; sourceTree = "<group>"; }; 4BC4B7D0BB4C6D8E4B0AA4AD /* radroots.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = radroots.xcconfig; sourceTree = "<group>"; }; 54EE5A34FE2086899F5B7568 /* radroots.git.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = radroots.git.xcconfig; sourceTree = "<group>"; }; + 59AC0543EF8335D691D56BD3 /* FieldBackgroundExecutionUITestProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldBackgroundExecutionUITestProbe.swift; sourceTree = "<group>"; }; 63189EB90A86A9929BECD9ED /* Nostr.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nostr.swift; sourceTree = "<group>"; }; 676B89EB116B60AE8C2B4313 /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Base.xcconfig; sourceTree = "<group>"; }; 6FBB081610305940C7849C7C /* RelaySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelaySettings.swift; sourceTree = "<group>"; }; @@ -195,6 +197,7 @@ children = ( A71EFADBC7D54AF5B9314773 /* BuildConfig.swift */, C614F2A8813E63C85261F492 /* FieldBackgroundExecution.swift */, + 59AC0543EF8335D691D56BD3 /* FieldBackgroundExecutionUITestProbe.swift */, 7CC1D11E2E296B4B54E7E8A9 /* FieldCaptureIntake.swift */, E4F2BDC19EF9435162BFF5EC /* FieldDocumentInterchange.swift */, EF05B944D348DAA4EF28B463 /* FieldDocumentInterchangeUITestProbe.swift */, @@ -432,6 +435,7 @@ 9121BD4A3E7C6EF2B21F540F /* CopyButton.swift in Sources */, D3834AF9A4E1327B7DA557F3 /* CopyRow.swift in Sources */, ABBA5CC10933CA087E14A0E8 /* FieldBackgroundExecution.swift in Sources */, + 2FAE0FC43EB547F2CE7A567D /* FieldBackgroundExecutionUITestProbe.swift in Sources */, 299A6111507F657670856F36 /* FieldCaptureIntake.swift in Sources */, 3FC570AC038C3DC575E5A3E7 /* FieldDocumentInterchange.swift in Sources */, E432FD39ECC8F03764EEED81 /* FieldDocumentInterchangeUITestProbe.swift in Sources */, diff --git a/Radroots/App/AppEntry.swift b/Radroots/App/AppEntry.swift @@ -50,6 +50,13 @@ public struct AppEntry<Main: View>: View { .accessibilityIdentifier("field_ios.telemetry.probe") .accessibilityValue(probeValue) } + if let probeValue = appState.backgroundExecutionProbeValue { + Color.clear + .frame(width: 1, height: 1) + .accessibilityElement() + .accessibilityIdentifier("field_ios.background_execution.probe") + .accessibilityValue(probeValue) + } } } } 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 telemetryProbeValue: String? + @Published public private(set) var backgroundExecutionProbeValue: String? @Published public private(set) var externalActionStatus: String? @Published public private(set) var userPresenceStatus: String? @Published public private(set) var canOpenNostrProfile: Bool = false @@ -140,6 +141,7 @@ public final class AppState: ObservableObject { let captureIntake = try FieldCaptureIntake.configured(bundleIdentifier: appBundleIdentifier) self.captureIntake = captureIntake try await backgroundExecution.start() + await refreshBackgroundExecutionProbe(using: backgroundExecution) await refreshRuntimeState(using: service) if runtimeIdentityReady && !isLocked { startConnectingAndPollingStatus(using: service) @@ -877,6 +879,10 @@ public final class AppState: ObservableObject { telemetryProbeValue = await FieldTelemetryUITestProbe.value(recordedBy: telemetry) } + private func refreshBackgroundExecutionProbe(using backgroundExecution: FieldBackgroundExecution) async { + backgroundExecutionProbeValue = await backgroundExecution.uiTestProbeValue() + } + 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/FieldBackgroundExecution.swift b/Radroots/Runtime/FieldBackgroundExecution.swift @@ -189,6 +189,19 @@ actor FieldBackgroundExecution { } } + func uiTestProbeValue() async -> String? { + guard FieldBackgroundExecutionUITestProbe.isRequested else { + return nil + } + do { + return try await buildUITestProbeValue() + } catch { + return FieldBackgroundExecutionUITestProbe.failureValue( + outcome: FieldTelemetry.backgroundExecutionOutcome(for: error) + ) + } + } + @discardableResult func performMaintenance(reason: String) async -> Bool { let transferCount = await inspectTransferSnapshots(reason: reason) @@ -206,6 +219,82 @@ actor FieldBackgroundExecution { return succeeded } + private func buildUITestProbeValue() async throws -> String { + let registered = hasRegisteredHandlers + let scheduledTaskCount = await fakeSubmittedRequestCount() + let pendingBeforeMaintenance = try await scheduler.pendingTasks().count + let transferSnapshotCount = try await seedUITestTransferSnapshot() + let stagedBlobRemoved = try await seedUITestStagedBlobAndRunMaintenance() + let pendingBeforeCancel = try await scheduler.pendingTasks().count + await cancelAll() + let pendingAfterCancel = try await scheduler.pendingTasks().count + let cancellationObserved = await fakeCancelAllCount() > 0 + try? await Task.sleep(nanoseconds: 100_000_000) + let events = await telemetry.recordedEventsForUITest() + return FieldBackgroundExecutionUITestProbe.value( + registered: registered, + scheduledTaskCount: scheduledTaskCount, + pendingBeforeMaintenance: pendingBeforeMaintenance, + pendingBeforeCancel: pendingBeforeCancel, + pendingAfterCancel: pendingAfterCancel, + cancellationObserved: cancellationObserved, + stagedBlobRemoved: stagedBlobRemoved, + transferSnapshotCount: transferSnapshotCount, + events: events + ) + } + + private func seedUITestTransferSnapshot() async throws -> Int { + guard let fakeTransfer = transfer as? RadrootsFakeBackgroundTransfer else { + return try await transfer.snapshots().count + } + let request = try RadrootsBackgroundTransferRequest( + remoteURL: URL(string: "https://radroots.org/field-ios-background-probe")!, + method: .get, + operation: .download( + destination: .file( + RadrootsFileReference( + scope: .cache, + relativePath: "ui_tests/background_execution/probe-download.bin" + ) + ) + ), + metadata: ["purpose": "background_execution_probe"] + ) + _ = try await fakeTransfer.enqueue(request) + return try await fakeTransfer.snapshots().count + } + + private func seedUITestStagedBlobAndRunMaintenance() async throws -> Bool { + let fileAccess = RadrootsAppleFileAccess(roots: roots) + let blob = try fileAccess.stageBlob( + Data("field-ios-background-probe".utf8), + mediaType: "text/plain", + filenameHint: "background-probe.txt" + ) + let blobURL = try roots.stagedBlobURL(for: blob) + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSince1970: 0)], + ofItemAtPath: blobURL.path + ) + _ = await performMaintenance(reason: "ui_test_probe") + return !FileManager.default.fileExists(atPath: blobURL.path) + } + + private func fakeSubmittedRequestCount() async -> Int { + guard let fakeScheduler = scheduler as? RadrootsFakeBackgroundTaskScheduler else { + return (try? await scheduler.pendingTasks().count) ?? 0 + } + return await fakeScheduler.submittedRequestCount + } + + private func fakeCancelAllCount() async -> Int { + guard let fakeScheduler = scheduler as? RadrootsFakeBackgroundTaskScheduler else { + return 0 + } + return await fakeScheduler.cancelAllCount + } + private func inspectTransferSnapshots(reason: String) async -> Int? { do { let snapshots = try await transfer.snapshots() diff --git a/Radroots/Runtime/FieldBackgroundExecutionUITestProbe.swift b/Radroots/Runtime/FieldBackgroundExecutionUITestProbe.swift @@ -0,0 +1,108 @@ +import Foundation +import RadrootsKit + +enum FieldBackgroundExecutionUITestProbe { + private static let enabledKey = "RADROOTS_FIELD_IOS_UI_TEST_BACKGROUND_EXECUTION_PROBE" + private static let redactionPolicy = RadrootsTelemetryRedactionPolicy.default + + static var isRequested: Bool { + ProcessInfo.processInfo.environment[enabledKey] == "true" + } + + static func value( + registered: Bool, + scheduledTaskCount: Int, + pendingBeforeMaintenance: Int, + pendingBeforeCancel: Int, + pendingAfterCancel: Int, + cancellationObserved: Bool, + stagedBlobRemoved: Bool, + transferSnapshotCount: Int, + events: [RadrootsTelemetryEvent] + ) -> String { + let eventNames = Set(events.map(\.name)) + let values = events.flatMap(stringValues) + return [ + "registered=\(registered)", + "scheduled_task_count=\(scheduledTaskCount)", + "schedule_observed=\(scheduledTaskCount >= 2)", + "pending_before_maintenance=\(pendingBeforeMaintenance)", + "pending_before_cancel=\(pendingBeforeCancel)", + "pending_after_cancel=\(pendingAfterCancel)", + "cancellation_observed=\(cancellationObserved)", + "staged_blob_removed=\(stagedBlobRemoved)", + "transfer_snapshot_count=\(transferSnapshotCount)", + "background_register_seen=\(eventNames.contains("field_ios.background_execution.register"))", + "background_schedule_seen=\(eventNames.contains("field_ios.background_execution.schedule"))", + "background_cancel_all_seen=\(eventNames.contains("field_ios.background_execution.cancel_all"))", + "background_transfer_inspect_seen=\(eventNames.contains("field_ios.background_execution.transfer_inspect"))", + "background_staged_blob_sweep_seen=\(eventNames.contains("field_ios.background_execution.staged_blob_sweep"))", + "background_relay_refresh_seen=\(eventNames.contains("field_ios.background_execution.relay_refresh"))", + "background_maintenance_seen=\(eventNames.contains("field_ios.background_execution.maintenance"))", + "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: ";") + } + + static func failureValue(outcome: String) -> String { + [ + "registered=false", + "scheduled_task_count=0", + "pending_before_maintenance=0", + "pending_before_cancel=0", + "pending_after_cancel=0", + "cancellation_observed=false", + "staged_blob_removed=false", + "transfer_snapshot_count=0", + "probe_failure_outcome=\(outcome)", + "unsafe_values_present=false", + "relay_url_values_present=false", + "secret_like_values_present=false", + "path_like_values_present=false", + "npub_values_present=false" + ].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") + } +}