field_ios

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

commit 3d5215d146a05fff52cb1f6c238de1741fe84b6f
parent 46cd51708df7a001f3582a04474ed60d84698864
Author: triesap <tyson@radroots.org>
Date:   Wed, 17 Jun 2026 17:30:18 -0700

field-ios: add Nostr-only background maintenance

- refresh Nostr relay status only when identity is unlocked
- inspect AppleKit transfer snapshots without remote upload behavior
- sweep expired AppleKit staged blobs during maintenance
- cancel scheduled background tasks during destructive identity reset

Diffstat:
MRadroots/App/AppState.swift | 21+++++++++++++++------
MRadroots/Runtime/FieldBackgroundExecution.swift | 168++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
MRadroots/Runtime/FieldTelemetry.swift | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
3 files changed, 222 insertions(+), 31 deletions(-)

diff --git a/Radroots/App/AppState.swift b/Radroots/App/AppState.swift @@ -115,6 +115,11 @@ public final class AppState: ObservableObject { let metadataStore = try FieldIdentityPublicMetadataStore.configured() let appBundleIdentifier = try bundleIdentifier() let resetLocalStateRequested = BuildConfig.bool(.resetLocalState) == true + let backgroundExecution = try FieldBackgroundExecution.configured( + bundleIdentifier: appBundleIdentifier, + telemetry: telemetry + ) + self.backgroundExecution = backgroundExecution try FieldFileAccessUITestProbe.seedDestructiveResetSentinelIfRequested( bundleIdentifier: appBundleIdentifier, resetLocalStateRequested: resetLocalStateRequested @@ -122,6 +127,7 @@ public final class AppState: ObservableObject { secureIdentityStore = secureStore identityMetadataStore = metadataStore if resetLocalStateRequested { + await backgroundExecution.cancelAll() try FieldLocalState.resetFileRoots(bundleIdentifier: appBundleIdentifier) try secureStore.deleteSelectedSecret() metadataStore.delete() @@ -133,11 +139,6 @@ 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 { @@ -193,7 +194,8 @@ public final class AppState: ObservableObject { public func appDidEnterBackground() { Task { - try? await backgroundExecution?.schedulePermittedTasks(reason: "background") + _ = try? await backgroundExecution?.schedulePermittedTasks(reason: "background") + await backgroundExecution?.performMaintenance(reason: "background") } } @@ -269,6 +271,8 @@ public final class AppState: ObservableObject { let service = try requireRuntimeService() do { try await requireUserPresence(for: .deleteIdentity) + await backgroundExecution?.updateRuntimeState(service: service, identityUnlocked: false) + await backgroundExecution?.cancelAll() try secureIdentityStoreOrConfigured().deleteSelectedSecret() try identityMetadataStoreOrConfigured().delete() try await resetRuntimeIdentityState(using: service) @@ -598,6 +602,10 @@ public final class AppState: ObservableObject { relayLastError = error.localizedDescription } await refreshRelayStatus(using: service) + await backgroundExecution?.updateRuntimeState( + service: service, + identityUnlocked: runtimeIdentityReady && !isLocked + ) } private func refreshRelayStatus(using service: FieldRuntimeService) async { @@ -722,6 +730,7 @@ public final class AppState: ObservableObject { } hasKey = storedIdentityAvailable await refreshRelayStatus(using: service) + await backgroundExecution?.updateRuntimeState(service: service, identityUnlocked: false) } private func apply(storedIdentity metadata: FieldIdentityPublicMetadata) { diff --git a/Radroots/Runtime/FieldBackgroundExecution.swift b/Radroots/Runtime/FieldBackgroundExecution.swift @@ -31,18 +31,24 @@ struct FieldBackgroundExecutionHandlers: Sendable { } actor FieldBackgroundExecution { + private static let stagedBlobRetention: TimeInterval = 24 * 60 * 60 + private let identifiers: FieldBackgroundTaskIdentifiers private let scheduler: any RadrootsBackgroundTaskScheduler private let transfer: any RadrootsBackgroundTransfer + private let roots: RadrootsAppleFileRoots private let telemetry: FieldTelemetry private let now: @Sendable () -> Date private let registerHandlers: @Sendable (FieldBackgroundExecutionHandlers) async throws -> Void + private var runtimeService: FieldRuntimeService? + private var identityUnlocked = false private var hasRegisteredHandlers = false init( identifiers: FieldBackgroundTaskIdentifiers, scheduler: any RadrootsBackgroundTaskScheduler, transfer: any RadrootsBackgroundTransfer, + roots: RadrootsAppleFileRoots, telemetry: FieldTelemetry, now: @escaping @Sendable () -> Date = Date.init, registerHandlers: @escaping @Sendable (FieldBackgroundExecutionHandlers) async throws -> Void @@ -50,6 +56,7 @@ actor FieldBackgroundExecution { self.identifiers = identifiers self.scheduler = scheduler self.transfer = transfer + self.roots = roots self.telemetry = telemetry self.now = now self.registerHandlers = registerHandlers @@ -60,6 +67,7 @@ actor FieldBackgroundExecution { telemetry: FieldTelemetry ) throws -> FieldBackgroundExecution { let identifiers = try FieldBackgroundTaskIdentifiers(bundleIdentifier: bundleIdentifier) + let roots = try FieldLocalState.roots(bundleIdentifier: bundleIdentifier) if uiTestWasRequested { let scheduler = RadrootsFakeBackgroundTaskScheduler() let transfer = RadrootsFakeBackgroundTransfer() @@ -67,12 +75,12 @@ actor FieldBackgroundExecution { identifiers: identifiers, scheduler: scheduler, transfer: transfer, + roots: roots, telemetry: telemetry, registerHandlers: { _ in } ) } let scheduler = RadrootsAppleBackgroundTaskScheduler() - let roots = try FieldLocalState.roots(bundleIdentifier: bundleIdentifier) let transfer = try RadrootsAppleBackgroundTransfer( roots: roots, sessionIdentifier: identifiers.transferSessionIdentifier @@ -81,6 +89,7 @@ actor FieldBackgroundExecution { identifiers: identifiers, scheduler: scheduler, transfer: transfer, + roots: roots, telemetry: telemetry, registerHandlers: { handlers in _ = try await scheduler.register( @@ -105,8 +114,12 @@ actor FieldBackgroundExecution { if !hasRegisteredHandlers { try await registerHandlers( FieldBackgroundExecutionHandlers( - refresh: { true }, - processing: { true } + refresh: { [weak self] in + await self?.performMaintenance(reason: "refresh_task") ?? false + }, + processing: { [weak self] in + await self?.performMaintenance(reason: "processing_task") ?? false + } ) ) hasRegisteredHandlers = true @@ -115,24 +128,38 @@ actor FieldBackgroundExecution { _ = try await schedulePermittedTasks(reason: "startup") } + func updateRuntimeState(service: FieldRuntimeService?, identityUnlocked: Bool) { + self.runtimeService = service + self.identityUnlocked = identityUnlocked + } + @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 + do { + 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 + } catch { + telemetry.backgroundExecution( + operation: "schedule", + outcome: FieldTelemetry.backgroundExecutionOutcome(for: error), + reason: reason + ) + throw error + } } func cancelAll() async { @@ -162,6 +189,109 @@ actor FieldBackgroundExecution { } } + @discardableResult + func performMaintenance(reason: String) async -> Bool { + let transferCount = await inspectTransferSnapshots(reason: reason) + let sweptCount = sweepExpiredStagedBlobs(reason: reason) + let relaySucceeded = await refreshRelaysIfAllowed(reason: reason) + let succeeded = transferCount != nil && sweptCount != nil && relaySucceeded + telemetry.backgroundExecution( + operation: "maintenance", + outcome: succeeded ? "success" : "partial_failure", + stagedBlobCount: sweptCount, + transferCount: transferCount, + identityUnlocked: identityUnlocked, + reason: reason + ) + return succeeded + } + + private func inspectTransferSnapshots(reason: String) async -> Int? { + do { + let snapshots = try await transfer.snapshots() + telemetry.backgroundExecution( + operation: "transfer_inspect", + outcome: "success", + transferCount: snapshots.count, + reason: reason + ) + return snapshots.count + } catch { + telemetry.backgroundExecution( + operation: "transfer_inspect", + outcome: FieldTelemetry.backgroundExecutionOutcome(for: error), + reason: reason + ) + return nil + } + } + + private func sweepExpiredStagedBlobs(reason: String) -> Int? { + do { + let fileAccess = RadrootsAppleFileAccess(roots: roots) + let swept = try fileAccess.sweepStagedBlobs( + olderThan: now().addingTimeInterval(-Self.stagedBlobRetention) + ) + telemetry.backgroundExecution( + operation: "staged_blob_sweep", + outcome: "success", + stagedBlobCount: swept.count, + reason: reason + ) + return swept.count + } catch { + telemetry.backgroundExecution( + operation: "staged_blob_sweep", + outcome: FieldTelemetry.backgroundExecutionOutcome(for: error), + reason: reason + ) + return nil + } + } + + private func refreshRelaysIfAllowed(reason: String) async -> Bool { + guard identityUnlocked else { + telemetry.backgroundExecution( + operation: "relay_refresh", + outcome: "skipped_locked", + identityUnlocked: false, + reason: reason + ) + return true + } + guard let runtimeService else { + telemetry.backgroundExecution( + operation: "relay_refresh", + outcome: "skipped_runtime_unavailable", + identityUnlocked: true, + reason: reason + ) + return true + } + do { + try await runtimeService.nostrSetDefaultRelays(try RelaySettings.relays()) + try await runtimeService.nostrConnectIfKeyPresent() + let status = await runtimeService.nostrConnectionStatus() + telemetry.backgroundExecution( + operation: "relay_refresh", + outcome: "success", + relayConnectedCount: status.connected, + relayConnectingCount: status.connecting, + identityUnlocked: true, + reason: reason + ) + return true + } catch { + telemetry.backgroundExecution( + operation: "relay_refresh", + outcome: FieldTelemetry.backgroundExecutionOutcome(for: error), + identityUnlocked: true, + reason: reason + ) + return false + } + } + private static var uiTestWasRequested: Bool { let environment = ProcessInfo.processInfo.environment let arguments = ProcessInfo.processInfo.arguments diff --git a/Radroots/Runtime/FieldTelemetry.swift b/Radroots/Runtime/FieldTelemetry.swift @@ -225,16 +225,50 @@ final class FieldTelemetry: @unchecked Sendable { operation: String, outcome: String, taskCount: Int? = nil, + stagedBlobCount: Int? = nil, + transferCount: Int? = nil, + relayConnectedCount: UInt32? = nil, + relayConnectingCount: UInt32? = nil, + identityUnlocked: Bool? = nil, reason: String? = nil ) { + let expectedOutcome = outcome == "success" || outcome.hasPrefix("skipped") + var fields: [RadrootsTelemetryField] = [] + if let field = try? RadrootsTelemetryField.string("outcome", outcome) { + fields.append(field) + } + if let taskCount, + let field = try? RadrootsTelemetryField.integer("task_count", taskCount) { + fields.append(field) + } + if let stagedBlobCount, + let field = try? RadrootsTelemetryField.integer("staged_blob_count", stagedBlobCount) { + fields.append(field) + } + if let transferCount, + let field = try? RadrootsTelemetryField.integer("transfer_count", transferCount) { + fields.append(field) + } + if let relayConnectedCount, + let field = try? RadrootsTelemetryField.integer("relay_connected_count", Int64(relayConnectedCount)) { + fields.append(field) + } + if let relayConnectingCount, + let field = try? RadrootsTelemetryField.integer("relay_connecting_count", Int64(relayConnectingCount)) { + fields.append(field) + } + if let identityUnlocked, + let field = try? RadrootsTelemetryField.bool("identity_unlocked", identityUnlocked) { + fields.append(field) + } + if let reason, + let field = try? RadrootsTelemetryField.string("reason", reason) { + fields.append(field) + } 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 } + level: expectedOutcome ? .info : .warning, + fields: fields ) } @@ -382,6 +416,24 @@ final class FieldTelemetry: @unchecked Sendable { case .persistenceFailure: return "persistence_failure" } + case let error as RadrootsAppleFileError: + switch error { + case .invalidRequest: + return "invalid_request" + case .notFound: + return "not_found" + case .permissionDenied: + return "permission_denied" + case .transientFailure: + return "transient_failure" + case .permanentFailure: + return "permanent_failure" + } + case let error as RelaySettingsError: + switch error { + case .noRelaysConfigured: + return "relay_config_missing" + } default: return "failure" }