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 }