field_ios

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

commit 46cd51708df7a001f3582a04474ed60d84698864
parent 7b6cb20ac3898f570961c9370a4665cb8a6556f3
Author: triesap <tyson@radroots.org>
Date:   Wed, 17 Jun 2026 17:24:13 -0700

field-ios: add background execution coordinator shell

- configure permitted background task identifiers and modes
- add AppleKit-backed background execution coordinator
- schedule refresh and processing tasks from lifecycle events
- record local background execution telemetry outcomes

Diffstat:
MRadroots.xcodeproj/project.pbxproj | 4++++
MRadroots/App/AppState.swift | 19+++++++++++++++++++
MRadroots/App/RadrootsProvider.swift | 13+++++++++++++
MRadroots/Info.plist | 10++++++++++
ARadroots/Runtime/FieldBackgroundExecution.swift | 171+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MRadroots/Runtime/FieldTelemetry.swift | 44++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 261 insertions(+), 0 deletions(-)

diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj @@ -38,6 +38,7 @@ 9121BD4A3E7C6EF2B21F540F /* CopyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4C7DE4207398DE242519F9C /* CopyButton.swift */; }; 9346DA48630668A65D37E14A /* FieldTelemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B4E53FD4C4AADF63855888A /* FieldTelemetry.swift */; }; A1B921027DA7ACD7343BE250 /* SectionWideButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17CA8F5611075F60F214A00 /* SectionWideButton.swift */; }; + ABBA5CC10933CA087E14A0E8 /* FieldBackgroundExecution.swift in Sources */ = {isa = PBXBuildFile; fileRef = C614F2A8813E63C85261F492 /* FieldBackgroundExecution.swift */; }; B8A3BBDE3A1FC0248512BF76 /* LoggingSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4DE3DD8C3BB2F63676F463E /* LoggingSettings.swift */; }; B971351ABE8E79A472B4DC7D /* View+Nav.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0B0C9861CD86EAD3CAD549E /* View+Nav.swift */; }; C22DB0F3EB2E69A34DF941E0 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2818363B157125491FB84A1E /* App.swift */; }; @@ -100,6 +101,7 @@ BD7B47A576C4D5CE9318D3E6 /* RadrootsFFI.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = RadrootsFFI.xcframework; path = Radroots/Frameworks/RadrootsFFI.xcframework; sourceTree = "<group>"; }; C17CA8F5611075F60F214A00 /* SectionWideButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SectionWideButton.swift; sourceTree = "<group>"; }; C1D9496F9F05A4E79E73A247 /* RelaysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelaysView.swift; sourceTree = "<group>"; }; + C614F2A8813E63C85261F492 /* FieldBackgroundExecution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldBackgroundExecution.swift; sourceTree = "<group>"; }; C71A93F98C7B93188748B99B /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; }; CA8AAF0C0F1723860A8481E0 /* PostCreateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostCreateView.swift; sourceTree = "<group>"; }; CA942715DB13FFD494FD35A0 /* FieldIdentityPublicMetadataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldIdentityPublicMetadataStore.swift; sourceTree = "<group>"; }; @@ -192,6 +194,7 @@ isa = PBXGroup; children = ( A71EFADBC7D54AF5B9314773 /* BuildConfig.swift */, + C614F2A8813E63C85261F492 /* FieldBackgroundExecution.swift */, 7CC1D11E2E296B4B54E7E8A9 /* FieldCaptureIntake.swift */, E4F2BDC19EF9435162BFF5EC /* FieldDocumentInterchange.swift */, EF05B944D348DAA4EF28B463 /* FieldDocumentInterchangeUITestProbe.swift */, @@ -428,6 +431,7 @@ E3864E34D67BAD0744B93180 /* Bundle+Build.swift in Sources */, 9121BD4A3E7C6EF2B21F540F /* CopyButton.swift in Sources */, D3834AF9A4E1327B7DA557F3 /* CopyRow.swift in Sources */, + ABBA5CC10933CA087E14A0E8 /* FieldBackgroundExecution.swift in Sources */, 299A6111507F657670856F36 /* FieldCaptureIntake.swift in Sources */, 3FC570AC038C3DC575E5A3E7 /* FieldDocumentInterchange.swift in Sources */, E432FD39ECC8F03764EEED81 /* FieldDocumentInterchangeUITestProbe.swift in Sources */, diff --git a/Radroots/App/AppState.swift b/Radroots/App/AppState.swift @@ -84,6 +84,7 @@ public final class AppState: ObservableObject { private var secureIdentityStore: FieldSecureIdentityStore? private var identityMetadataStore: FieldIdentityPublicMetadataStore? private var captureIntake: FieldCaptureIntake? + private var backgroundExecution: FieldBackgroundExecution? private let locationCheckIn = FieldLocationCheckIn.configured() private let externalActions = FieldExternalActions.configured() private let userPresenceGate = FieldUserPresenceGate.configured() @@ -132,6 +133,12 @@ public final class AppState: ObservableObject { } let captureIntake = try FieldCaptureIntake.configured(bundleIdentifier: appBundleIdentifier) self.captureIntake = captureIntake + let backgroundExecution = try FieldBackgroundExecution.configured( + bundleIdentifier: appBundleIdentifier, + telemetry: telemetry + ) + self.backgroundExecution = backgroundExecution + try await backgroundExecution.start() await refreshRuntimeState(using: service) if runtimeIdentityReady && !isLocked { startConnectingAndPollingStatus(using: service) @@ -178,6 +185,18 @@ public final class AppState: ObservableObject { } } + public func appDidBecomeActive() { + Task { + try? await backgroundExecution?.schedulePermittedTasks(reason: "active") + } + } + + public func appDidEnterBackground() { + Task { + try? await backgroundExecution?.schedulePermittedTasks(reason: "background") + } + } + public func continueWithLocalIdentity() async throws { let service = try requireRuntimeService() do { diff --git a/Radroots/App/RadrootsProvider.swift b/Radroots/App/RadrootsProvider.swift @@ -1,6 +1,7 @@ import SwiftUI public struct RadrootsProvider<Content: View>: View { + @Environment(\.scenePhase) private var scenePhase @StateObject private var appState = AppState() private let onStartupError: ((Error) -> Void)? private let content: () -> Content @@ -24,5 +25,17 @@ public struct RadrootsProvider<Content: View>: View { onStartupError?(error) } } + .onChange(of: scenePhase) { _, phase in + switch phase { + case .active: + appState.appDidBecomeActive() + case .background: + appState.appDidEnterBackground() + case .inactive: + break + @unknown default: + break + } + } } } diff --git a/Radroots/Info.plist b/Radroots/Info.plist @@ -20,6 +20,11 @@ <string>1.0</string> <key>CFBundleVersion</key> <string>1</string> + <key>BGTaskSchedulerPermittedIdentifiers</key> + <array> + <string>$(PRODUCT_BUNDLE_IDENTIFIER).background.refresh</string> + <string>$(PRODUCT_BUNDLE_IDENTIFIER).background.processing</string> + </array> <key>NSLocationWhenInUseUsageDescription</key> <string>Radroots uses your location only when you tap Location Check-in.</string> <key>NSCameraUsageDescription</key> @@ -57,6 +62,11 @@ <key>UIImageName</key> <string>RadrootsSplashLogomark</string> </dict> + <key>UIBackgroundModes</key> + <array> + <string>fetch</string> + <string>processing</string> + </array> <key>UISupportedInterfaceOrientations</key> <array> diff --git a/Radroots/Runtime/FieldBackgroundExecution.swift b/Radroots/Runtime/FieldBackgroundExecution.swift @@ -0,0 +1,171 @@ +import Foundation +import RadrootsKit +import RadrootsKitTesting + +struct FieldBackgroundTaskIdentifiers: Equatable, Sendable { + let refresh: RadrootsBackgroundTaskIdentifier + let processing: RadrootsBackgroundTaskIdentifier + let transferSessionIdentifier: String + + init(bundleIdentifier: String) throws { + let normalized = try FieldBackgroundTaskIdentifiers.normalizedBundleIdentifier(bundleIdentifier) + self.refresh = try RadrootsBackgroundTaskIdentifier("\(normalized).background.refresh") + self.processing = try RadrootsBackgroundTaskIdentifier("\(normalized).background.processing") + self.transferSessionIdentifier = try RadrootsBackgroundTransferValidation.normalizedIdentifier( + "\(normalized).background.transfer" + ) + } + + private static func normalizedBundleIdentifier(_ value: String) throws -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !trimmed.isEmpty else { + throw FieldLocalStateError.missingBundleIdentifier + } + return trimmed + } +} + +struct FieldBackgroundExecutionHandlers: Sendable { + let refresh: @Sendable () async -> Bool + let processing: @Sendable () async -> Bool +} + +actor FieldBackgroundExecution { + private let identifiers: FieldBackgroundTaskIdentifiers + private let scheduler: any RadrootsBackgroundTaskScheduler + private let transfer: any RadrootsBackgroundTransfer + private let telemetry: FieldTelemetry + private let now: @Sendable () -> Date + private let registerHandlers: @Sendable (FieldBackgroundExecutionHandlers) async throws -> Void + private var hasRegisteredHandlers = false + + init( + identifiers: FieldBackgroundTaskIdentifiers, + scheduler: any RadrootsBackgroundTaskScheduler, + transfer: any RadrootsBackgroundTransfer, + telemetry: FieldTelemetry, + now: @escaping @Sendable () -> Date = Date.init, + registerHandlers: @escaping @Sendable (FieldBackgroundExecutionHandlers) async throws -> Void + ) { + self.identifiers = identifiers + self.scheduler = scheduler + self.transfer = transfer + self.telemetry = telemetry + self.now = now + self.registerHandlers = registerHandlers + } + + static func configured( + bundleIdentifier: String, + telemetry: FieldTelemetry + ) throws -> FieldBackgroundExecution { + let identifiers = try FieldBackgroundTaskIdentifiers(bundleIdentifier: bundleIdentifier) + if uiTestWasRequested { + let scheduler = RadrootsFakeBackgroundTaskScheduler() + let transfer = RadrootsFakeBackgroundTransfer() + return FieldBackgroundExecution( + identifiers: identifiers, + scheduler: scheduler, + transfer: transfer, + telemetry: telemetry, + registerHandlers: { _ in } + ) + } + let scheduler = RadrootsAppleBackgroundTaskScheduler() + let roots = try FieldLocalState.roots(bundleIdentifier: bundleIdentifier) + let transfer = try RadrootsAppleBackgroundTransfer( + roots: roots, + sessionIdentifier: identifiers.transferSessionIdentifier + ) + return FieldBackgroundExecution( + identifiers: identifiers, + scheduler: scheduler, + transfer: transfer, + telemetry: telemetry, + registerHandlers: { handlers in + _ = try await scheduler.register( + RadrootsAppleBackgroundTaskRegistration( + identifier: identifiers.refresh, + kind: .appRefresh, + handler: handlers.refresh + ) + ) + _ = try await scheduler.register( + RadrootsAppleBackgroundTaskRegistration( + identifier: identifiers.processing, + kind: .processing, + handler: handlers.processing + ) + ) + } + ) + } + + func start() async throws { + if !hasRegisteredHandlers { + try await registerHandlers( + FieldBackgroundExecutionHandlers( + refresh: { true }, + processing: { true } + ) + ) + hasRegisteredHandlers = true + telemetry.backgroundExecution(operation: "register", outcome: "success", taskCount: 2) + } + _ = try await schedulePermittedTasks(reason: "startup") + } + + @discardableResult + func schedulePermittedTasks(reason: String) async throws -> [RadrootsBackgroundTaskSnapshot] { + let refresh = try RadrootsBackgroundTaskRequest( + identifier: identifiers.refresh, + kind: .appRefresh, + earliestBeginDate: now().addingTimeInterval(15 * 60) + ) + let processing = try RadrootsBackgroundTaskRequest( + identifier: identifiers.processing, + kind: .processing, + earliestBeginDate: now().addingTimeInterval(60 * 60) + ) + let snapshots = [ + try await scheduler.submit(refresh), + try await scheduler.submit(processing) + ] + telemetry.backgroundExecution(operation: "schedule", outcome: "success", taskCount: snapshots.count, reason: reason) + return snapshots + } + + func cancelAll() async { + do { + try await scheduler.cancelAll() + telemetry.backgroundExecution(operation: "cancel_all", outcome: "success") + } catch { + telemetry.backgroundExecution(operation: "cancel_all", outcome: FieldTelemetry.backgroundExecutionOutcome(for: error)) + } + } + + func pendingTaskSnapshots() async -> [RadrootsBackgroundTaskSnapshot] { + do { + return try await scheduler.pendingTasks() + } catch { + telemetry.backgroundExecution(operation: "pending_tasks", outcome: FieldTelemetry.backgroundExecutionOutcome(for: error)) + return [] + } + } + + func transferSnapshots() async -> [RadrootsBackgroundTransferSnapshot] { + do { + return try await transfer.snapshots() + } catch { + telemetry.backgroundExecution(operation: "transfer_snapshots", outcome: FieldTelemetry.backgroundExecutionOutcome(for: error)) + return [] + } + } + + private static var uiTestWasRequested: Bool { + let environment = ProcessInfo.processInfo.environment + let arguments = ProcessInfo.processInfo.arguments + return environment["RADROOTS_FIELD_IOS_UI_TEST"] == "true" || + arguments.contains("--radroots-field-ios-ui-test") + } +} diff --git a/Radroots/Runtime/FieldTelemetry.swift b/Radroots/Runtime/FieldTelemetry.swift @@ -221,6 +221,23 @@ final class FieldTelemetry: @unchecked Sendable { ) } + func backgroundExecution( + operation: String, + outcome: String, + taskCount: Int? = nil, + reason: String? = nil + ) { + record( + name: "field_ios.background_execution.\(operation)", + level: outcome == "success" ? .info : .warning, + fields: [ + try? .string("outcome", outcome), + taskCount.map { try? .integer("task_count", $0) } ?? nil, + reason.map { try? .string("reason", $0) } ?? nil + ].compactMap { $0 } + ) + } + func recordedEventsForUITest() async -> [RadrootsTelemetryEvent] { guard let recordingTelemetry else { return [] @@ -343,6 +360,33 @@ final class FieldTelemetry: @unchecked Sendable { } } + static func backgroundExecutionOutcome(for error: Error) -> String { + switch error { + case let error as RadrootsBackgroundTaskError: + switch error { + case .invalidRequest: + return "invalid_request" + case .unavailable: + return "unavailable" + case .schedulerFailure: + return "scheduler_failure" + } + case let error as RadrootsBackgroundTransferError: + switch error { + case .invalidRequest: + return "invalid_request" + case .unavailable: + return "unavailable" + case .transferFailure: + return "transfer_failure" + case .persistenceFailure: + return "persistence_failure" + } + default: + return "failure" + } + } + static func externalActionOutcome(for error: Error) -> String { guard let error = error as? RadrootsExternalActionError else { return "failure"