field_ios

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

FieldBackgroundExecution.swift (17002B)


      1 import Foundation
      2 import RadrootsKit
      3 
      4 struct FieldBackgroundTaskIdentifiers: Equatable, Sendable {
      5     let refresh: RadrootsBackgroundTaskIdentifier
      6     let processing: RadrootsBackgroundTaskIdentifier
      7     let transferSessionIdentifier: String
      8 
      9     init(bundleIdentifier: String) throws {
     10         let normalized = try FieldBackgroundTaskIdentifiers.normalizedBundleIdentifier(bundleIdentifier)
     11         self.refresh = try RadrootsBackgroundTaskIdentifier("\(normalized).background.refresh")
     12         self.processing = try RadrootsBackgroundTaskIdentifier("\(normalized).background.processing")
     13         self.transferSessionIdentifier = try RadrootsBackgroundTransferValidation.normalizedIdentifier(
     14             "\(normalized).background.transfer"
     15         )
     16     }
     17 
     18     private static func normalizedBundleIdentifier(_ value: String) throws -> String {
     19         let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
     20         guard !trimmed.isEmpty else {
     21             throw FieldLocalStateError.missingBundleIdentifier
     22         }
     23         return trimmed
     24     }
     25 }
     26 
     27 struct FieldBackgroundExecutionHandlers: Sendable {
     28     let refresh: @Sendable () async -> Bool
     29     let processing: @Sendable () async -> Bool
     30 }
     31 
     32 actor FieldBackgroundExecution {
     33     private static let stagedBlobRetention: TimeInterval = 24 * 60 * 60
     34 
     35     private let identifiers: FieldBackgroundTaskIdentifiers
     36     private let scheduler: any RadrootsBackgroundTaskScheduler
     37     private let transfer: any RadrootsBackgroundTransfer
     38     private let roots: RadrootsAppleFileRoots
     39     private let telemetry: FieldTelemetry
     40     private let now: @Sendable () -> Date
     41     private let registerHandlers: @Sendable (FieldBackgroundExecutionHandlers) async throws -> Void
     42     private var runtimeService: FieldRuntimeService?
     43     private var identityUnlocked = false
     44     private var hasRegisteredHandlers = false
     45 
     46     init(
     47         identifiers: FieldBackgroundTaskIdentifiers,
     48         scheduler: any RadrootsBackgroundTaskScheduler,
     49         transfer: any RadrootsBackgroundTransfer,
     50         roots: RadrootsAppleFileRoots,
     51         telemetry: FieldTelemetry,
     52         now: @escaping @Sendable () -> Date = Date.init,
     53         registerHandlers: @escaping @Sendable (FieldBackgroundExecutionHandlers) async throws -> Void
     54     ) {
     55         self.identifiers = identifiers
     56         self.scheduler = scheduler
     57         self.transfer = transfer
     58         self.roots = roots
     59         self.telemetry = telemetry
     60         self.now = now
     61         self.registerHandlers = registerHandlers
     62     }
     63 
     64     static func configured(
     65         bundleIdentifier: String,
     66         telemetry: FieldTelemetry
     67     ) throws -> FieldBackgroundExecution {
     68         let identifiers = try FieldBackgroundTaskIdentifiers(bundleIdentifier: bundleIdentifier)
     69         let roots = try FieldLocalState.roots(bundleIdentifier: bundleIdentifier)
     70         #if DEBUG
     71         if FieldUITestHarness.isRequested {
     72             let scheduler = FieldUITestBackgroundTaskScheduler()
     73             let transfer = FieldUITestBackgroundTransfer()
     74             let now: @Sendable () -> Date
     75             if FieldBackgroundExecutionUITestProbe.isRequested {
     76                 now = { Date.distantFuture }
     77             } else {
     78                 now = Date.init
     79             }
     80             return FieldBackgroundExecution(
     81                 identifiers: identifiers,
     82                 scheduler: scheduler,
     83                 transfer: transfer,
     84                 roots: roots,
     85                 telemetry: telemetry,
     86                 now: now,
     87                 registerHandlers: { _ in }
     88             )
     89         }
     90         #endif
     91         let scheduler = RadrootsAppleBackgroundTaskScheduler()
     92         let transfer = try RadrootsAppleBackgroundTransfer(
     93             roots: roots,
     94             sessionIdentifier: identifiers.transferSessionIdentifier
     95         )
     96         return FieldBackgroundExecution(
     97             identifiers: identifiers,
     98             scheduler: scheduler,
     99             transfer: transfer,
    100             roots: roots,
    101             telemetry: telemetry,
    102             registerHandlers: { handlers in
    103                 let refreshRegistered = try await scheduler.register(
    104                     RadrootsAppleBackgroundTaskRegistration(
    105                         identifier: identifiers.refresh,
    106                         kind: .appRefresh,
    107                         handler: handlers.refresh
    108                     )
    109                 )
    110                 let processingRegistered = try await scheduler.register(
    111                     RadrootsAppleBackgroundTaskRegistration(
    112                         identifier: identifiers.processing,
    113                         kind: .processing,
    114                         handler: handlers.processing
    115                     )
    116                 )
    117                 guard refreshRegistered && processingRegistered else {
    118                     throw RadrootsBackgroundTaskError.schedulerFailure(
    119                         "background task handler registration was rejected"
    120                     )
    121                 }
    122             }
    123         )
    124     }
    125 
    126     func start() async throws {
    127         if !hasRegisteredHandlers {
    128             do {
    129                 try await registerHandlers(
    130                     FieldBackgroundExecutionHandlers(
    131                         refresh: { [weak self] in
    132                             await self?.performMaintenance(reason: "refresh_task") ?? false
    133                         },
    134                         processing: { [weak self] in
    135                             await self?.performMaintenance(reason: "processing_task") ?? false
    136                         }
    137                     )
    138                 )
    139                 hasRegisteredHandlers = true
    140                 telemetry.backgroundExecution(operation: "handler_registration", outcome: "success", taskCount: 2)
    141             } catch {
    142                 hasRegisteredHandlers = false
    143                 telemetry.backgroundExecution(
    144                     operation: "handler_registration",
    145                     outcome: FieldTelemetry.backgroundExecutionOutcome(for: error)
    146                 )
    147                 throw error
    148             }
    149         }
    150         _ = try await schedulePermittedTasks(reason: "startup")
    151     }
    152 
    153     func updateRuntimeState(service: FieldRuntimeService?, identityUnlocked: Bool) {
    154         self.runtimeService = service
    155         self.identityUnlocked = identityUnlocked
    156     }
    157 
    158     @discardableResult
    159     func schedulePermittedTasks(reason: String) async throws -> [RadrootsBackgroundTaskSnapshot] {
    160         do {
    161             guard hasRegisteredHandlers else {
    162                 throw RadrootsBackgroundTaskError.schedulerFailure(
    163                     "background task handlers are not registered"
    164                 )
    165             }
    166             let refresh = try RadrootsBackgroundTaskRequest(
    167                 identifier: identifiers.refresh,
    168                 kind: .appRefresh,
    169                 earliestBeginDate: now().addingTimeInterval(15 * 60)
    170             )
    171             let processing = try RadrootsBackgroundTaskRequest(
    172                 identifier: identifiers.processing,
    173                 kind: .processing,
    174                 earliestBeginDate: now().addingTimeInterval(60 * 60)
    175             )
    176             let snapshots = [
    177                 try await scheduler.submit(refresh),
    178                 try await scheduler.submit(processing)
    179             ]
    180             telemetry.backgroundExecution(operation: "schedule", outcome: "success", taskCount: snapshots.count, reason: reason)
    181             return snapshots
    182         } catch {
    183             telemetry.backgroundExecution(
    184                 operation: "schedule",
    185                 outcome: FieldTelemetry.backgroundExecutionOutcome(for: error),
    186                 reason: reason
    187             )
    188             throw error
    189         }
    190     }
    191 
    192     func cancelAll() async {
    193         do {
    194             try await scheduler.cancelAll()
    195             telemetry.backgroundExecution(operation: "cancel_all", outcome: "success")
    196         } catch {
    197             telemetry.backgroundExecution(operation: "cancel_all", outcome: FieldTelemetry.backgroundExecutionOutcome(for: error))
    198         }
    199     }
    200 
    201     func pendingTaskSnapshots() async -> [RadrootsBackgroundTaskSnapshot] {
    202         do {
    203             return try await scheduler.pendingTasks()
    204         } catch {
    205             telemetry.backgroundExecution(operation: "pending_tasks", outcome: FieldTelemetry.backgroundExecutionOutcome(for: error))
    206             return []
    207         }
    208     }
    209 
    210     func transferSnapshots() async -> [RadrootsBackgroundTransferSnapshot] {
    211         do {
    212             return try await transfer.snapshots()
    213         } catch {
    214             telemetry.backgroundExecution(operation: "transfer_snapshots", outcome: FieldTelemetry.backgroundExecutionOutcome(for: error))
    215             return []
    216         }
    217     }
    218 
    219     func handleEventsForBackgroundURLSession(
    220         identifier: String,
    221         completionHandler: @escaping @Sendable () -> Void
    222     ) async {
    223         await transfer.handleEventsForBackgroundURLSession(
    224             identifier: identifier,
    225             completionHandler: completionHandler
    226         )
    227         telemetry.backgroundExecution(operation: "background_url_session_events", outcome: "success")
    228     }
    229 
    230     func uiTestProbeValue() async -> String? {
    231         guard FieldBackgroundExecutionUITestProbe.isRequested else {
    232             return nil
    233         }
    234         do {
    235             return try await buildUITestProbeValue()
    236         } catch {
    237             return FieldBackgroundExecutionUITestProbe.failureValue(
    238                 outcome: FieldTelemetry.backgroundExecutionOutcome(for: error)
    239             )
    240         }
    241     }
    242 
    243     @discardableResult
    244     func performMaintenance(reason: String) async -> Bool {
    245         let transferCount = await inspectTransferSnapshots(reason: reason)
    246         let sweptCount = sweepExpiredStagedBlobs(reason: reason)
    247         let relaySucceeded = await refreshRelaysIfAllowed(reason: reason)
    248         let succeeded = transferCount != nil && sweptCount != nil && relaySucceeded
    249         telemetry.backgroundExecution(
    250             operation: "maintenance",
    251             outcome: succeeded ? "success" : "partial_failure",
    252             stagedBlobCount: sweptCount,
    253             transferCount: transferCount,
    254             identityUnlocked: identityUnlocked,
    255             reason: reason
    256         )
    257         return succeeded
    258     }
    259 
    260     private func buildUITestProbeValue() async throws -> String {
    261         let registered = hasRegisteredHandlers
    262         let scheduledTaskCount = await fakeSubmittedRequestCount()
    263         let pendingBeforeMaintenance = try await scheduler.pendingTasks().count
    264         let transferSnapshotCount = try await seedUITestTransferSnapshot()
    265         let stagedBlobRemoved = try await seedUITestStagedBlobAndRunMaintenance()
    266         let pendingBeforeCancel = try await scheduler.pendingTasks().count
    267         await cancelAll()
    268         let pendingAfterCancel = try await scheduler.pendingTasks().count
    269         let cancellationObserved = await fakeCancelAllCount() > 0
    270         try? await Task.sleep(nanoseconds: 100_000_000)
    271         let events = await telemetry.recordedEventsForUITest()
    272         return FieldBackgroundExecutionUITestProbe.value(
    273             registered: registered,
    274             scheduledTaskCount: scheduledTaskCount,
    275             pendingBeforeMaintenance: pendingBeforeMaintenance,
    276             pendingBeforeCancel: pendingBeforeCancel,
    277             pendingAfterCancel: pendingAfterCancel,
    278             cancellationObserved: cancellationObserved,
    279             stagedBlobRemoved: stagedBlobRemoved,
    280             transferSnapshotCount: transferSnapshotCount,
    281             events: events
    282         )
    283     }
    284 
    285     private func seedUITestTransferSnapshot() async throws -> Int {
    286         #if DEBUG
    287         guard let fakeTransfer = transfer as? FieldUITestBackgroundTransfer else {
    288             return try await transfer.snapshots().count
    289         }
    290         let request = try RadrootsBackgroundTransferRequest(
    291             remoteURL: URL(string: "https://radroots.org/field-ios-background-probe")!,
    292             method: .get,
    293             operation: .download(
    294                 destination: .file(
    295                     RadrootsFileReference(
    296                         scope: .cache,
    297                         relativePath: "ui_tests/background_execution/probe-download.bin"
    298                     )
    299                 )
    300             ),
    301             metadata: ["purpose": "background_execution_probe"]
    302         )
    303         _ = try await fakeTransfer.enqueue(request)
    304         return try await fakeTransfer.snapshots().count
    305         #else
    306         return try await transfer.snapshots().count
    307         #endif
    308     }
    309 
    310     private func seedUITestStagedBlobAndRunMaintenance() async throws -> Bool {
    311         let fileAccess = RadrootsAppleFileAccess(roots: roots)
    312         let blob = try fileAccess.stageBlob(
    313             Data("field-ios-background-probe".utf8),
    314             mediaType: "text/plain",
    315             filenameHint: "background-probe.txt"
    316         )
    317         _ = await performMaintenance(reason: "ui_test_probe")
    318         do {
    319             _ = try fileAccess.readStagedBlob(blob)
    320             return false
    321         } catch RadrootsAppleFileError.notFound {
    322             return true
    323         } catch {
    324             throw error
    325         }
    326     }
    327 
    328     private func fakeSubmittedRequestCount() async -> Int {
    329         #if DEBUG
    330         guard let fakeScheduler = scheduler as? FieldUITestBackgroundTaskScheduler else {
    331             return (try? await scheduler.pendingTasks().count) ?? 0
    332         }
    333         return await fakeScheduler.submittedRequestCount
    334         #else
    335         return (try? await scheduler.pendingTasks().count) ?? 0
    336         #endif
    337     }
    338 
    339     private func fakeCancelAllCount() async -> Int {
    340         #if DEBUG
    341         guard let fakeScheduler = scheduler as? FieldUITestBackgroundTaskScheduler else {
    342             return 0
    343         }
    344         return await fakeScheduler.cancelAllCount
    345         #else
    346         return 0
    347         #endif
    348     }
    349 
    350     private func inspectTransferSnapshots(reason: String) async -> Int? {
    351         do {
    352             let snapshots = try await transfer.snapshots()
    353             telemetry.backgroundExecution(
    354                 operation: "transfer_inspect",
    355                 outcome: "success",
    356                 transferCount: snapshots.count,
    357                 reason: reason
    358             )
    359             return snapshots.count
    360         } catch {
    361             telemetry.backgroundExecution(
    362                 operation: "transfer_inspect",
    363                 outcome: FieldTelemetry.backgroundExecutionOutcome(for: error),
    364                 reason: reason
    365             )
    366             return nil
    367         }
    368     }
    369 
    370     private func sweepExpiredStagedBlobs(reason: String) -> Int? {
    371         do {
    372             let fileAccess = RadrootsAppleFileAccess(roots: roots)
    373             let swept = try fileAccess.sweepStagedBlobs(
    374                 olderThan: now().addingTimeInterval(-Self.stagedBlobRetention)
    375             )
    376             telemetry.backgroundExecution(
    377                 operation: "staged_blob_sweep",
    378                 outcome: "success",
    379                 stagedBlobCount: swept.count,
    380                 reason: reason
    381             )
    382             return swept.count
    383         } catch {
    384             telemetry.backgroundExecution(
    385                 operation: "staged_blob_sweep",
    386                 outcome: FieldTelemetry.backgroundExecutionOutcome(for: error),
    387                 reason: reason
    388             )
    389             return nil
    390         }
    391     }
    392 
    393     private func refreshRelaysIfAllowed(reason: String) async -> Bool {
    394         guard identityUnlocked else {
    395             telemetry.backgroundExecution(
    396                 operation: "relay_refresh",
    397                 outcome: "skipped_locked",
    398                 identityUnlocked: false,
    399                 reason: reason
    400             )
    401             return true
    402         }
    403         guard let runtimeService else {
    404             telemetry.backgroundExecution(
    405                 operation: "relay_refresh",
    406                 outcome: "skipped_runtime_unavailable",
    407                 identityUnlocked: true,
    408                 reason: reason
    409             )
    410             return true
    411         }
    412         do {
    413             let relaySettings = try RelaySettings.effectiveSnapshot(bundleIdentifier: roots.appIdentifier)
    414             try await runtimeService.nostrSetDefaultRelays(relaySettings.relays)
    415             try await runtimeService.nostrConnectIfKeyPresent()
    416             let status = await runtimeService.nostrConnectionStatus()
    417             telemetry.backgroundExecution(
    418                 operation: "relay_refresh",
    419                 outcome: "success",
    420                 relayConnectedCount: status.connected,
    421                 relayConnectingCount: status.connecting,
    422                 identityUnlocked: true,
    423                 reason: reason
    424             )
    425             return true
    426         } catch {
    427             telemetry.backgroundExecution(
    428                 operation: "relay_refresh",
    429                 outcome: FieldTelemetry.backgroundExecutionOutcome(for: error),
    430                 identityUnlocked: true,
    431                 reason: reason
    432             )
    433             return false
    434         }
    435     }
    436 
    437 }