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:
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"