field_ios

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

TeraAddMediaCoordinator.swift (17473B)


      1 import Foundation
      2 import RadrootsKit
      3 
      4 struct TeraAddBackgroundUploadReceipt: Sendable, Equatable {
      5   let identifier: String
      6   let draftID: String
      7   let expectedRevision: UInt64
      8   let statusCode: UInt16
      9   let mediaType: String?
     10   let contentEncoding: String?
     11   let body: Data
     12 }
     13 
     14 protocol TeraAddMediaHandling: Sendable {
     15   func renewSubmissionUpload(_ submission: TeraSubmissionStatus, media: TeraPreparedMedia, client: TeraRuntimeClient) async throws -> TeraSubmissionStatus
     16   func recoverNativeUploads(client: TeraRuntimeClient) async throws -> TeraNativeRecoveryProgress
     17   func recoverNativeUpload(key: String, client: TeraRuntimeClient) async throws -> TeraNativeRecoveryProgress
     18   func confirmDurableComposerMedia(_ media: [TeraComposerMedia]) async throws
     19   func prefersSharedForegroundUpload(ownerID: String) async throws -> Bool
     20   func support() async throws -> TeraAddMediaSupport
     21   func importImages(limit: Int) async throws -> [TeraPreparedMedia]
     22   func captureImage() async throws -> TeraPreparedMedia
     23   func open(_ media: [TeraPreparedMedia]) async throws -> TeraOpenedMedia
     24   func uploadInBackground(
     25     job: TeraNativeUploadJob,
     26     media: TeraPreparedMedia
     27   ) async throws -> TeraAddBackgroundUploadReceipt
     28   func uploadInBackground(transfer: TeraNativeTransferJob, media: TeraPreparedMedia) async throws -> TeraAddBackgroundUploadReceipt
     29   func reconcileBackgroundSubmissions(_ submissions: [TeraSubmissionStatus], client: TeraRuntimeClient) async throws
     30   func retainedSubmissionUpload(_ submission: TeraSubmissionStatus, media: TeraPreparedMedia) async throws -> TeraAddBackgroundUploadReceipt?
     31   func settleBackgroundUpload(identifier: String, accepted: Bool) async throws
     32   func reconcileBackgroundUploads(drafts: [TeraDraftStatus], client: TeraRuntimeClient) async throws
     33 }
     34 
     35 extension TeraAddMediaHandling {
     36   func recoverNativeUpload(key _: String, client _: TeraRuntimeClient) async throws -> TeraNativeRecoveryProgress {
     37     throw TeraComposerAcknowledgment.unconfirmed
     38   }
     39 
     40   func renewSubmissionUpload(_: TeraSubmissionStatus, media _: TeraPreparedMedia, client _: TeraRuntimeClient) async throws -> TeraSubmissionStatus {
     41     throw TeraComposerAcknowledgment.unconfirmed
     42   }
     43 
     44   func recoverNativeUploads(client _: TeraRuntimeClient) async throws -> TeraNativeRecoveryProgress {
     45     throw TeraComposerAcknowledgment.unconfirmed
     46   }
     47 
     48   func confirmDurableComposerMedia(_ media: [TeraComposerMedia]) async throws {
     49     guard media.isEmpty else { throw TeraComposerAcknowledgment.unconfirmed }
     50   }
     51 
     52   func prefersSharedForegroundUpload(ownerID _: String) async throws -> Bool {
     53     // A conformer must explicitly establish native capability before selecting it.
     54     // The shared uploader retains the required destination enforcement by default.
     55     true
     56   }
     57 
     58   func uploadInBackground(job: TeraNativeUploadJob, media: TeraPreparedMedia) async throws -> TeraAddBackgroundUploadReceipt {
     59     try await uploadInBackground(transfer: job.transfer, media: media)
     60   }
     61 
     62   func uploadInBackground(transfer _: TeraNativeTransferJob, media _: TeraPreparedMedia) async throws -> TeraAddBackgroundUploadReceipt {
     63     throw TeraRuntimeFailure.local(
     64       operation: "add.media.background", code: "ios.add.background_transfer_unavailable",
     65       safeMessage: "Background photo upload is unavailable on this device."
     66     )
     67   }
     68 
     69   func reconcileBackgroundSubmissions(_: [TeraSubmissionStatus], client: TeraRuntimeClient) async throws {
     70     _ = try? await recoverNativeUploads(client: client)
     71   }
     72 
     73   func retainedSubmissionUpload(_: TeraSubmissionStatus, media _: TeraPreparedMedia) async throws -> TeraAddBackgroundUploadReceipt? {
     74     nil
     75   }
     76 
     77   func settleBackgroundUpload(identifier _: String, accepted _: Bool) async throws {}
     78 
     79   func reconcileBackgroundUploads(drafts _: [TeraDraftStatus], client: TeraRuntimeClient) async throws {
     80     _ = try? await recoverNativeUploads(client: client)
     81   }
     82 }
     83 
     84 actor TeraAddMediaCoordinator: TeraAddMediaHandling {
     85   private let roots: RadrootsAppleFileRoots
     86   private let picker: any RadrootsMediaPicker
     87   private let preparer: RadrootsAppleMediaPreparer
     88   private let transfer: any RadrootsBackgroundTransfer
     89   private let clock: TeraClock
     90   private let cameraAccess: @Sendable () -> TeraCameraAccess
     91   /// Reserve before request preparation or native callbacks. A cancelled waiter
     92   /// releases this caller's admission; OS transfer state remains authoritative.
     93   private var activeUploadDrafts: Set<String> = []
     94   private var recoveryActive = false
     95 
     96   func renewSubmissionUpload(_ submission: TeraSubmissionStatus, media: TeraPreparedMedia, client: TeraRuntimeClient) async throws -> TeraSubmissionStatus {
     97     guard activeUploadDrafts.insert(submission.intentID).inserted else { throw TeraBackgroundUploadRequest.operationInProgress }
     98     defer { activeUploadDrafts.remove(submission.intentID) }
     99     let roots = roots, transfer = transfer, preparer = preparer
    100     return try await client.withUploadRenewal { backend in
    101       let mediaUse = try TeraMediaProcessUse.admit(root: roots.dataRoot)
    102       defer { withExtendedLifetime(mediaUse) {} }
    103       let opened = try await self.open([media])
    104       defer { opened.close() }
    105       guard let handle = opened.handles.first else { throw TeraComposerAcknowledgment.unconfirmed }
    106       return try await TeraUploadRenewal(transfer: transfer, preparer: preparer)
    107         .run(submission, media: media, handle: handle, backend: backend)
    108     }
    109   }
    110 
    111   func confirmDurableComposerMedia(_ media: [TeraComposerMedia]) throws {
    112     let mediaUse = try TeraMediaProcessUse.admit(root: roots.dataRoot)
    113     defer { withExtendedLifetime(mediaUse) {} }
    114     try TeraComposerMediaOwnership.confirm(media, roots: roots)
    115   }
    116 
    117   func prefersSharedForegroundUpload(ownerID: String) async throws -> Bool {
    118     let mediaUse = try TeraMediaProcessUse.admit(root: roots.dataRoot)
    119     defer { withExtendedLifetime(mediaUse) {} }
    120     guard !RadrootsAppleBackgroundTransferAdapters.supportsNewEnqueue(for: .publicHTTPS) else { return false }
    121     // Preserve an existing native attempt for its own reconciliation path.
    122     // An uncertain native result never authorizes a second foreground PUT.
    123     let prefix = "radroots.add.\(ownerID)."
    124     return try await !transfer.snapshots().contains {
    125       $0.identifier.rawValue.hasPrefix(prefix) && $0.state != .completed
    126     }
    127   }
    128 
    129   init(
    130     roots: RadrootsAppleFileRoots,
    131     picker: any RadrootsMediaPicker,
    132     preparer: RadrootsAppleMediaPreparer,
    133     transfer: any RadrootsBackgroundTransfer,
    134     clock: TeraClock = .system,
    135     cameraAccess: @escaping @Sendable () -> TeraCameraAccess = TeraCameraAccess.current
    136   ) {
    137     self.roots = roots
    138     self.picker = picker
    139     self.preparer = preparer
    140     self.transfer = transfer
    141     self.clock = clock
    142     self.cameraAccess = cameraAccess
    143   }
    144 
    145   static func production(
    146     bundleIdentifier: String,
    147     transfer: any RadrootsBackgroundTransfer
    148   ) throws -> Self {
    149     let roots = try TeraRemoteQualificationEnvironment.applicationFileRoots(
    150       appIdentifier: bundleIdentifier
    151     )
    152     let fileAccess = RadrootsAppleFileAccess(roots: roots)
    153     let picker: any RadrootsMediaPicker
    154     #if DEBUG
    155       if let mediaFile = try TeraRemoteQualificationEnvironment.current()?.mediaFile {
    156         picker = TeraRemoteQualificationMediaPicker(
    157           roots: roots,
    158           file: mediaFile
    159         )
    160       } else {
    161         picker = RadrootsAppleMediaPicker(fileAccess: fileAccess)
    162       }
    163     #else
    164       picker = RadrootsAppleMediaPicker(fileAccess: fileAccess)
    165     #endif
    166     return Self(
    167       roots: roots,
    168       picker: picker,
    169       preparer: RadrootsAppleMediaPreparer(roots: roots),
    170       transfer: transfer
    171     )
    172   }
    173 
    174   func support() async throws -> TeraAddMediaSupport {
    175     let value = try await picker.currentSupport()
    176     return TeraAddMediaSupport(
    177       library: value.importAvailable && value.supportedImportKinds.contains(.image),
    178       camera: value.cameraCaptureAvailable && value.supportedCaptureKinds.contains(.image),
    179       cameraAccess: cameraAccess()
    180     )
    181   }
    182 
    183   func importImages(limit: Int) async throws -> [TeraPreparedMedia] {
    184     let mediaUse = try TeraMediaProcessUse.admit(root: roots.dataRoot)
    185     defer { withExtendedLifetime(mediaUse) {} }
    186     let result = try await picker.importMedia(
    187       RadrootsMediaImportRequest(
    188         allowedMediaKinds: [.image],
    189         selectionLimit: min(max(limit, 1), 20),
    190         destinationScope: .cache
    191       )
    192     )
    193     var prepared: [TeraPreparedMedia] = []
    194     for asset in result.items {
    195       try await prepared.append(prepare(asset))
    196     }
    197     return prepared
    198   }
    199 
    200   func captureImage() async throws -> TeraPreparedMedia {
    201     let capability = try await support()
    202     guard capability.camera else {
    203       throw TeraRuntimeFailure.local(operation: "add.media.camera", code: "ios.camera.unavailable",
    204                                      safeMessage: capability.cameraAccess.guidance)
    205     }
    206     let mediaUse = try TeraMediaProcessUse.admit(root: roots.dataRoot)
    207     defer { withExtendedLifetime(mediaUse) {} }
    208     let result = try await picker.captureMedia(
    209       RadrootsMediaCaptureRequest(mediaKind: .image, destinationScope: .cache)
    210     )
    211     return try await prepare(result.item)
    212   }
    213 
    214   func open(_ media: [TeraPreparedMedia]) throws -> TeraOpenedMedia {
    215     let mediaUse = try TeraMediaProcessUse.admit(root: roots.dataRoot)
    216     defer { withExtendedLifetime(mediaUse) {} }
    217     var files: [FileHandle] = []
    218     var handles: [TeraPreparedMediaHandle] = []
    219     do {
    220       for item in media {
    221         guard item.opaqueReference == "media:\(item.sha256)",
    222           item.mediaType == "image/png",
    223           let byteSize = Int(exactly: item.byteSize)
    224         else {
    225           throw TeraRuntimeFailure.local(
    226             operation: "add.media.open",
    227             code: "ios.add.media_reference_invalid",
    228             safeMessage: "A prepared photo is no longer available."
    229           )
    230         }
    231         let blob = try RadrootsStagedBlobReference(
    232           blobID: item.sha256,
    233           sizeBytes: byteSize,
    234           mediaType: item.mediaType,
    235           filenameHint: "\(item.sha256).png"
    236         )
    237         try TeraDurableMediaRoots.restoreLegacyBlob(blob, roots: roots)
    238         let file = try FileHandle(forReadingFrom: roots.stagedBlobURL(for: blob))
    239         files.append(file)
    240         try handles.append(
    241           TeraPreparedMediaHandle(
    242             media: item,
    243             fileDescriptor: UInt64(file.fileDescriptor)
    244           )
    245         )
    246       }
    247       return TeraOpenedMedia(handles: handles, files: files)
    248     } catch {
    249       for file in files {
    250         try? file.close()
    251       }
    252       throw error
    253     }
    254   }
    255 
    256   func uploadInBackground(
    257     transfer job: TeraNativeTransferJob,
    258     media: TeraPreparedMedia
    259   ) async throws -> TeraAddBackgroundUploadReceipt {
    260     try Task.checkCancellation()
    261     let mediaUse = try TeraMediaProcessUse.admit(root: roots.dataRoot)
    262     defer { withExtendedLifetime(mediaUse) {} }
    263     guard activeUploadDrafts.insert(job.ownerID).inserted else {
    264       throw TeraBackgroundUploadRequest.operationInProgress
    265     }
    266     defer { activeUploadDrafts.remove(job.ownerID) }
    267     let request = try await TeraBackgroundUploadRequest.prepare(
    268       job: job, media: media, preparer: preparer
    269     )
    270     let persisted = try await TeraBackgroundUploadWaiter.matchingPersistedUpload(transfer: transfer,
    271                                                                                  draftID: job.ownerID,
    272                                                                                  expectedRevision: job.expectedRevision,
    273                                                                                  request: request)
    274     let active: RadrootsBackgroundTransferSnapshot
    275     if let persisted {
    276       if [.failed, .interrupted, .cancelled, .expired].contains(persisted.state) {
    277         let retry = try TeraBackgroundUploadRequest.replacingIdentifier(in: request, with: persisted.identifier)
    278         try Task.checkCancellation()
    279         active = try await TeraNativeUploadExecution.start(transfer: transfer, request: retry, retrying: true)
    280       } else {
    281         active = persisted
    282       }
    283     } else {
    284       try Task.checkCancellation()
    285       active = try await TeraNativeUploadExecution.start(transfer: transfer, request: request, retrying: false)
    286     }
    287     return try await TeraBackgroundUploadWaiter.receipt(transfer: transfer,
    288                                                         for: active.identifier,
    289                                                         draftID: job.ownerID,
    290                                                         expectedRevision: job.expectedRevision,
    291                                                         request: request, baseline: active)
    292   }
    293 
    294   func settleBackgroundUpload(identifier: String, accepted: Bool) async throws {
    295     let mediaUse = try TeraMediaProcessUse.admit(root: roots.dataRoot)
    296     defer { withExtendedLifetime(mediaUse) {} }
    297     do {
    298       let value = try RadrootsBackgroundTransferIdentifier(identifier)
    299       if let snapshot = try await transfer.snapshot(for: value),
    300         snapshot.state == .completed, accepted
    301       {
    302         return
    303       }
    304       try await transfer.settle(
    305         value,
    306         verification: accepted
    307           ? .accepted
    308           : .rejected(failure: .verificationRejected)
    309       )
    310     } catch is CancellationError {
    311       throw CancellationError()
    312     } catch {
    313       throw TeraRuntimeFailure.local(
    314         operation: "add.media.background.settle",
    315         code: "ios.add.background_settlement_failed",
    316         safeMessage: "The verified photo transfer could not be finalized."
    317       )
    318     }
    319   }
    320 
    321   func retainedSubmissionUpload(_ submission: TeraSubmissionStatus, media: TeraPreparedMedia) async throws -> TeraAddBackgroundUploadReceipt? {
    322     let mediaUse = try TeraMediaProcessUse.admit(root: roots.dataRoot)
    323     defer { withExtendedLifetime(mediaUse) {} }
    324     return try await TeraStoppedUploadRecovery.receipt(submission, media: media, transfer: transfer)
    325   }
    326 
    327   private func prepare(_ asset: RadrootsMediaAsset) async throws -> TeraPreparedMedia {
    328     let prepared = try await preparer.prepareImage(
    329       RadrootsAppleImagePreparationRequest(source: .file(asset.file))
    330     )
    331     return try TeraPreparedMedia(
    332       opaqueReference: "media:\(prepared.sha256)",
    333       remoteURL: nil,
    334       sha256: prepared.sha256,
    335       mediaType: "image/png",
    336       byteSize: UInt64(prepared.file.sizeBytes),
    337       width: prepared.width,
    338       height: prepared.height,
    339       alt: "",
    340       preparedAtUnixSeconds: clock.unixSeconds()
    341     )
    342   }
    343 
    344   private static func failure(code: String, message: String) -> TeraRuntimeFailure {
    345     .local(operation: "add.media.background", code: code, safeMessage: message)
    346   }
    347 }
    348 
    349 extension TeraAddMediaCoordinator {
    350   func recoverNativeUploads(client: TeraRuntimeClient) async throws -> TeraNativeRecoveryProgress {
    351     try await recoverNativeUploads(selectedKey: nil, client: client)
    352   }
    353 
    354   func recoverNativeUpload(key: String, client: TeraRuntimeClient) async throws -> TeraNativeRecoveryProgress {
    355     guard key.utf8.count == 64, key.utf8.allSatisfy({ (48 ... 57).contains($0) || (97 ... 102).contains($0) }) else {
    356       throw TeraComposerAcknowledgment.unconfirmed
    357     }
    358     return try await recoverNativeUploads(selectedKey: key, client: client)
    359   }
    360 
    361   private func recoverNativeUploads(selectedKey: String?, client: TeraRuntimeClient) async throws -> TeraNativeRecoveryProgress {
    362     guard !recoveryActive else { throw TeraBackgroundUploadRequest.operationInProgress }
    363     let mediaUse = try TeraMediaProcessUse.admit(root: roots.dataRoot)
    364     recoveryActive = true
    365     defer { recoveryActive = false; withExtendedLifetime(mediaUse) {} }
    366     let schedule = selectedKey == nil ? try await client.nativeRecoverySchedule() : nil
    367     let continuation = schedule.map { TeraNativeRecoveryContinuation($0, client: client) }
    368     let inspection = TeraNativeRecoveryInspection(selectedKey: selectedKey, completedNeedsRepair: { key in
    369       guard let status = try await client.nativeRecoveryStatus(key: key) else { return false }
    370       return status.reason != .resolved
    371     })
    372     let result = try await TeraNativeRecoveryInventory.run(transfer: transfer, cursor: schedule?.after, inspection: inspection, checkpoint: { key in
    373       try await continuation?.advance(key)
    374     }, complete: { snapshot, owner in
    375       let input = try TeraRecoveryUploadReceipt(snapshot: snapshot, owner: owner)
    376       let opened = try await self.open([input.media])
    377       defer { opened.close() }
    378       guard let handle = opened.handles.first else { throw TeraComposerAcknowledgment.unconfirmed }
    379       let receipt = try await client.recoverNativeUpload(input, media: handle)
    380       try Task.checkCancellation()
    381       try receipt.confirm(input)
    382       try await self.settleRecoveredUpload(snapshot, input: input, receipt: receipt)
    383     }, report: { snapshot, reason in
    384       await TeraNativeRecoveryClassification.report(snapshot.identifier.rawValue, reason: reason, client: client)
    385     }, lookup: { key in try await client.recoveryUploadOwner(key: key) })
    386     return result.progress
    387   }
    388 
    389   private func settleRecoveredUpload(_ snapshot: RadrootsBackgroundTransferSnapshot, input: TeraRecoveryUploadReceipt,
    390                                      receipt: TeraRecoveryCompletionReceipt) async throws
    391   {
    392     try await TeraNativeUploadReconciliation.settle(snapshot, input: input, receipt: receipt, transfer: transfer)
    393   }
    394 }