-
Notifications
You must be signed in to change notification settings - Fork 1.1k
/
MediaCoordinator.swift
795 lines (674 loc) · 32.6 KB
/
MediaCoordinator.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
import Foundation
import WordPressFlux
import class AutomatticTracks.CrashLogging
import enum Alamofire.AFError
/// MediaCoordinator is responsible for creating and uploading new media
/// items, independently of a specific view controller. It should be accessed
/// via the `shared` singleton.
///
class MediaCoordinator: NSObject {
@objc static let shared = MediaCoordinator()
private let coreDataStack: CoreDataStackSwift
private var mainContext: NSManagedObjectContext {
coreDataStack.mainContext
}
private let syncOperationQueue: OperationQueue = {
let queue = OperationQueue()
queue.name = "org.wordpress.mediauploadcoordinator.sync"
queue.maxConcurrentOperationCount = 1
return queue
}()
private let queue = DispatchQueue(label: "org.wordpress.mediauploadcoordinator")
// MARK: - Progress Coordinators
private let progressCoordinatorQueue = DispatchQueue(label: "org.wordpress.mediaprogresscoordinator", attributes: .concurrent)
/// Tracks uploads that don't belong to a specific post
private lazy var mediaLibraryProgressCoordinator: MediaProgressCoordinator = {
let coordinator = MediaProgressCoordinator()
coordinator.delegate = self
return coordinator
}()
/// Tracks uploads of media for specific posts
private var postMediaProgressCoordinators = [AbstractPost: MediaProgressCoordinator]()
private let mediaServiceFactory: MediaService.Factory
init(_ mediaServiceFactory: MediaService.Factory = MediaService.Factory(), coreDataStack: CoreDataStackSwift = ContextManager.shared) {
self.mediaServiceFactory = mediaServiceFactory
self.coreDataStack = coreDataStack
super.init()
addObserverForDeletedFiles()
}
/// Uploads all failed media for the post, and returns `true` if it was possible to start
/// uploads for all of the existing media for the post.
///
/// - Parameters:
/// - post: the post to get the media to upload from.
/// - automatedRetry: true if this call is the result of an automated upload-retry attempt.
///
/// - Returns: `true` if all media in the post is uploading or was uploaded, `false` otherwise.
///
func uploadMedia(for post: AbstractPost, automatedRetry: Bool = false) -> Bool {
let failedMedia: [Media] = post.media.filter({ $0.remoteStatus == .failed })
let mediasToUpload: [Media]
if automatedRetry {
mediasToUpload = Media.failedForUpload(in: post, automatedRetry: automatedRetry)
} else {
mediasToUpload = failedMedia
}
mediasToUpload.forEach { mediaObject in
retryMedia(mediaObject, automatedRetry: automatedRetry)
}
let isPushingAllPendingMedia = mediasToUpload.count == failedMedia.count
return isPushingAllPendingMedia
}
/// - returns: The progress coordinator for the specified post. If a coordinator
/// does not exist, one will be created.
private func coordinator(for post: AbstractPost) -> MediaProgressCoordinator {
if let cachedCoordinator = cachedCoordinator(for: post) {
return cachedCoordinator
}
// Use the original post so we don't create new coordinators for post revisions
let original = post.original ?? post
let coordinator = MediaProgressCoordinator()
coordinator.delegate = self
progressCoordinatorQueue.async(flags: .barrier) {
self.postMediaProgressCoordinators[original] = coordinator
}
return coordinator
}
/// - returns: The progress coordinator for the specified post, or nil
/// if one does not exist.
private func cachedCoordinator(for post: AbstractPost) -> MediaProgressCoordinator? {
// Use the original post so we don't create new coordinators for post revisions
let original = post.original ?? post
return progressCoordinatorQueue.sync {
return postMediaProgressCoordinators[original]
}
}
/// - returns: The progress coordinator for the specified media item. Either
/// returns a post coordinator if the media item has a post, otherwise
/// returns the general media library coordinator.
private func coordinator(for media: Media) -> MediaProgressCoordinator {
// Media which is just being uploaded should only belong to at most one post
if let post = media.posts?.first as? AbstractPost {
return coordinator(for: post)
}
return mediaLibraryProgressCoordinator
}
private func removeCoordinator(_ progressCoordinator: MediaProgressCoordinator) {
progressCoordinatorQueue.async(flags: .barrier) {
if let index = self.postMediaProgressCoordinators.firstIndex(where: { $0.value == progressCoordinator }) {
self.postMediaProgressCoordinators.remove(at: index)
}
}
}
// MARK: - Adding Media
/// Adds the specified media asset to the specified blog. The upload process
/// can be observed by adding an observer block using the `addObserver(_:for:)` method.
///
/// - parameter asset: The asset to add.
/// - parameter blog: The blog that the asset should be added to.
/// - parameter origin: The location in the app where the upload was initiated (optional).
///
@discardableResult
func addMedia(from asset: ExportableAsset, to blog: Blog, analyticsInfo: MediaAnalyticsInfo? = nil) -> Media? {
let coordinator = mediaLibraryProgressCoordinator
return addMedia(from: asset, blog: blog, post: nil, coordinator: coordinator, analyticsInfo: analyticsInfo)
}
/// Adds the specified media asset to the specified post. The upload process
/// can be observed by adding an observer block using the `addObserver(_:for:)` method.
///
/// - parameter asset: The asset to add.
/// - parameter post: The post that the asset should be added to.
/// - parameter origin: The location in the app where the upload was initiated (optional).
///
@discardableResult
func addMedia(from asset: ExportableAsset, to post: AbstractPost, analyticsInfo: MediaAnalyticsInfo? = nil) -> Media? {
let coordinator = self.coordinator(for: post)
return addMedia(from: asset, blog: post.blog, post: post, coordinator: coordinator, analyticsInfo: analyticsInfo)
}
@discardableResult
private func addMedia(from asset: ExportableAsset, blog: Blog, post: AbstractPost?, coordinator: MediaProgressCoordinator, analyticsInfo: MediaAnalyticsInfo? = nil) -> Media? {
coordinator.track(numberOfItems: 1)
let service = mediaServiceFactory.create(mainContext)
let totalProgress = Progress.discreteProgress(totalUnitCount: MediaExportProgressUnits.done)
var creationProgress: Progress? = nil
let mediaOptional = service.createMedia(with: asset,
blog: blog,
post: post,
progress: &creationProgress,
thumbnailCallback: { [weak self] media, url in
self?.thumbnailReady(url: url, for: media)
},
completion: { [weak self] media, error in
guard let strongSelf = self else {
return
}
if let error = error as NSError? {
if let media = media {
coordinator.attach(error: error as NSError, toMediaID: media.uploadID)
strongSelf.fail(error as NSError, media: media)
} else {
// If there was an error and we don't have a media object we just say to the coordinator that one item was finished
coordinator.finishOneItem()
}
return
}
guard let media = media, !media.isDeleted else {
return
}
strongSelf.trackUploadOf(media, analyticsInfo: analyticsInfo)
let uploadProgress = strongSelf.uploadMedia(media)
totalProgress.addChild(uploadProgress, withPendingUnitCount: MediaExportProgressUnits.threeQuartersDone)
})
guard let media = mediaOptional else {
return nil
}
processing(media)
if let creationProgress = creationProgress {
totalProgress.addChild(creationProgress, withPendingUnitCount: MediaExportProgressUnits.quarterDone)
coordinator.track(progress: totalProgress, of: media, withIdentifier: media.uploadID)
}
return media
}
/// Retry the upload of a media object that previously has failed.
///
/// - Parameters:
/// - media: the media object to retry the upload
/// - automatedRetry: whether the retry was automatically or manually initiated.
///
func retryMedia(_ media: Media, automatedRetry: Bool = false, analyticsInfo: MediaAnalyticsInfo? = nil) {
guard media.remoteStatus == .failed else {
DDLogError("Can't retry Media upload that hasn't failed. \(String(describing: media))")
return
}
trackRetryUploadOf(media, analyticsInfo: analyticsInfo)
let coordinator = self.coordinator(for: media)
coordinator.track(numberOfItems: 1)
let uploadProgress = uploadMedia(media, automatedRetry: automatedRetry)
coordinator.track(progress: uploadProgress, of: media, withIdentifier: media.uploadID)
}
/// Starts the upload of an already existing local media object
///
/// - Parameter media: the media to upload
/// - Parameter post: the post where media is being inserted
/// - parameter origin: The location in the app where the upload was initiated (optional).
///
func addMedia(_ media: Media, to post: AbstractPost, analyticsInfo: MediaAnalyticsInfo? = nil) {
guard media.remoteStatus == .local else {
DDLogError("Can't try to upload Media that isn't local only. \(String(describing: media))")
return
}
media.addPostsObject(post)
let coordinator = self.coordinator(for: post)
coordinator.track(numberOfItems: 1)
trackUploadOf(media, analyticsInfo: analyticsInfo)
let uploadProgress = uploadMedia(media)
coordinator.track(progress: uploadProgress, of: media, withIdentifier: media.uploadID)
}
/// Cancels any ongoing upload of the Media and deletes it.
///
/// - Parameter media: the object to cancel and delete
///
func cancelUploadAndDeleteMedia(_ media: Media) {
cancelUpload(of: media)
delete(media: [media])
}
/// Cancels any ongoing upload for the media object
///
/// - Parameter media: the media object to cancel the upload
///
func cancelUpload(of media: Media) {
coordinator(for: media).cancelAndStopTrack(of: media.uploadID)
}
/// Cancels all ongoing uploads
///
func cancelUploadOfAllMedia(for post: AbstractPost) {
coordinator(for: post).cancelAndStopAllInProgressMedia()
}
/// Deletes a single Media object. If the object is currently being uploaded,
/// the upload will be canceled.
///
/// - Parameter media: The media object to delete
/// - Parameter onProgress: Optional progress block, called after each media item is deleted
/// - Parameter success: Optional block called after all media items are deleted successfully
/// - Parameter failure: Optional block called if deletion failed for any media items,
/// after attempted deletion of all media items
///
func delete(_ media: Media, onProgress: ((Progress?) -> Void)? = nil, success: (() -> Void)? = nil, failure: (() -> Void)? = nil) {
delete(media: [media], onProgress: onProgress, success: success, failure: failure)
}
/// Deletes media objects. If the objects are currently being uploaded,
/// the uploads will be canceled.
///
/// - Parameter media: The media objects to delete
/// - Parameter onProgress: Optional progress block, called after each media item is deleted
/// - Parameter success: Optional block called after all media items are deleted successfully
/// - Parameter failure: Optional block called if deletion failed for any media items,
/// after attempted deletion of all media items
///
func delete(media: [Media], onProgress: ((Progress?) -> Void)? = nil, success: (() -> Void)? = nil, failure: (() -> Void)? = nil) {
media.forEach({ self.cancelUpload(of: $0) })
coreDataStack.performAndSave { context in
let service = self.mediaServiceFactory.create(context)
service.deleteMedia(media,
progress: { onProgress?($0) },
success: success,
failure: failure)
}
}
@discardableResult
private func uploadMedia(_ media: Media, automatedRetry: Bool = false) -> Progress {
let resultProgress = Progress.discreteProgress(totalUnitCount: 100)
let success: () -> Void = {
self.end(media)
}
let failure: (Error?) -> Void = { error in
// Ideally the upload service should always return an error. This may be easier to enforce
// if we update the service to Swift, but in the meanwhile I'm instantiating an unknown upload
// error whenever the service doesn't provide one.
//
let nserror = error as NSError?
?? NSError(
domain: MediaServiceErrorDomain,
code: MediaServiceError.unknownUploadError.rawValue,
userInfo: [
"filename": media.filename ?? "",
"filesize": media.filesize ?? "",
"height": media.height ?? "",
"width": media.width ?? "",
"localURL": media.localURL ?? "",
"remoteURL": media.remoteURL ?? "",
])
self.coordinator(for: media).attach(error: nserror, toMediaID: media.uploadID)
self.fail(nserror, media: media)
}
coreDataStack.performAndSave { context in
let service = self.mediaServiceFactory.create(context)
var progress: Progress? = nil
service.uploadMedia(media, automatedRetry: automatedRetry, progress: &progress, success: success, failure: failure)
if let progress {
resultProgress.addChild(progress, withPendingUnitCount: resultProgress.totalUnitCount)
}
}
uploading(media, progress: resultProgress)
return resultProgress
}
private func trackUploadOf(_ media: Media, analyticsInfo: MediaAnalyticsInfo?) {
guard let info = analyticsInfo else {
return
}
guard let event = info.eventForMediaType(media.mediaType) else {
// Fall back to the WPShared event tracking
trackUploadViaWPSharedOf(media, analyticsInfo: analyticsInfo)
return
}
let properties = info.properties(for: media)
WPAnalytics.track(event, properties: properties, blog: media.blog)
}
private func trackUploadViaWPSharedOf(_ media: Media, analyticsInfo: MediaAnalyticsInfo?) {
guard let info = analyticsInfo,
let event = info.wpsharedEventForMediaType(media.mediaType) else {
return
}
let properties = info.properties(for: media)
WPAppAnalytics.track(event,
withProperties: properties,
with: media.blog)
}
private func trackRetryUploadOf(_ media: Media, analyticsInfo: MediaAnalyticsInfo?) {
guard let info = analyticsInfo,
let event = info.retryEvent else {
return
}
let properties = info.properties(for: media)
WPAppAnalytics.track(event,
withProperties: properties,
with: media.blog)
}
// MARK: - Progress
/// - returns: The current progress for the specified media object.
///
func progress(for media: Media) -> Progress? {
return coordinator(for: media).progress(forMediaID: media.uploadID)
}
/// The global value of progress for all tasks running on the coordinator for the specified post.
///
func totalProgress(for post: AbstractPost) -> Double {
return cachedCoordinator(for: post)?.totalProgress ?? 0
}
/// Returns the error associated to media if any
///
/// - Parameter media: the media object from where to fetch the associated error.
/// - Returns: the error associated to media if any
///
func error(for media: Media) -> NSError? {
return coordinator(for: media).error(forMediaID: media.uploadID)
}
/// Returns the media object for the specified uploadID.
///
/// - Parameter uploadID: the identifier for an ongoing upload
/// - Returns: The media object for the specified uploadID.
///
func media(withIdentifier uploadID: String, for post: AbstractPost) -> Media? {
return coordinator(for: post).media(withIdentifier: uploadID)
}
/// Returns an existing media objcect with the specificed objectID
///
/// - Parameter objectID: the object unique ID
/// - Returns: an media object if it exists.
///
func media(withObjectID objectID: String) -> Media? {
guard let storeCoordinator = mainContext.persistentStoreCoordinator,
let url = URL(string: objectID),
let managedObjectID = storeCoordinator.safeManagedObjectID(forURIRepresentation: url),
let media = try? mainContext.existingObject(with: managedObjectID) as? Media else {
return nil
}
return media
}
/// Returns true if any media is being processed or uploading
///
func isUploadingMedia(for post: AbstractPost) -> Bool {
return cachedCoordinator(for: post)?.isRunning ?? false
}
/// Returns true if there is any media with a fail state
///
@objc
func hasFailedMedia(for post: AbstractPost) -> Bool {
return cachedCoordinator(for: post)?.hasFailedMedia ?? false
}
/// Return an array with all failed media IDs
///
func failedMediaIDs(for post: AbstractPost) -> [String] {
return cachedCoordinator(for: post)?.failedMediaIDs ?? []
}
// MARK: - Observing
typealias ObserverBlock = (Media, MediaState) -> Void
private var mediaObservers = [UUID: MediaObserver]()
/// Add an observer to receive updates when media items are updated.
///
/// - parameter onUpdate: A block that will be called whenever media items
/// (or a specific media item) are updated. The update
/// block will always be called on the main queue.
/// - parameter media: An optional specific media item to receive updates for.
/// If provided, the `onUpdate` block will only be called
/// for updates to this media item, otherwise it will be
/// called when changes occur to _any_ media item.
/// - returns: A UUID that can be used to unregister the observer block at a later time.
///
@discardableResult
func addObserver(_ onUpdate: @escaping ObserverBlock, for media: Media? = nil) -> UUID {
let uuid = UUID()
let observer = MediaObserver(
subject: media.flatMap({ .media(id: $0.objectID) }) ?? .all,
onUpdate: onUpdate
)
queue.async {
self.mediaObservers[uuid] = observer
}
return uuid
}
/// Add an observer to receive updates when media items for a post are updated.
///
/// - parameter onUpdate: A block that will be called whenever media items
/// associated with the specified post are updated.
/// The update block will always be called on the main queue.
/// - parameter post: The post to receive updates for. The `onUpdate` block
/// for any upload progress changes for any media associated
/// with this post via its media relationship.
/// - returns: A UUID that can be used to unregister the observer block at a later time.
///
@discardableResult
func addObserver(_ onUpdate: @escaping ObserverBlock, forMediaFor post: AbstractPost) -> UUID {
let uuid = UUID()
let original = post.original ?? post
let observer = MediaObserver(subject: .post(id: original.objectID), onUpdate: onUpdate)
queue.async {
self.mediaObservers[uuid] = observer
}
return uuid
}
/// Removes the observer block for the specified UUID.
///
/// - parameter uuid: The UUID that matches the observer to be removed.
///
@objc
func removeObserver(withUUID uuid: UUID) {
queue.async {
self.mediaObservers[uuid] = nil
}
}
/// Encapsulates the state of a media item.
///
enum MediaState: CustomDebugStringConvertible {
case processing
case thumbnailReady(url: URL)
case uploading(progress: Progress)
case ended
case failed(error: NSError)
case progress(value: Double)
var debugDescription: String {
switch self {
case .processing:
return "Processing"
case .thumbnailReady(let url):
return "Thumbnail Ready: \(url)"
case .uploading:
return "Uploading"
case .ended:
return "Ended"
case .failed(let error):
return "Failed: \(error)"
case .progress(let value):
return "Progress: \(value)"
}
}
}
/// Encapsulates an observer block and an optional observed media item or post.
private struct MediaObserver {
enum Subject: Equatable {
case media(id: NSManagedObjectID)
case post(id: NSManagedObjectID)
case all
}
let subject: Subject
let onUpdate: ObserverBlock
}
/// Utility method to return all observers for a `Media` item with the given `NSManagedObjectID`
/// and part of the posts with given `NSManagedObjectID`s, including any 'wildcard' observers
/// that are observing _all_ media items.
///
private func observersForMedia(withObjectID mediaObjectID: NSManagedObjectID, originalPostIDs: [NSManagedObjectID]) -> [MediaObserver] {
let mediaObservers = self.mediaObservers.values.filter({ $0.subject == .media(id: mediaObjectID) })
let postObservers = self.mediaObservers.values.filter({
guard case let .post(postObjectID) = $0.subject else { return false }
return originalPostIDs.contains(postObjectID)
})
return mediaObservers + postObservers + wildcardObservers
}
/// Utility method to return all 'wildcard' observers that are
/// observing _all_ media items.
///
private var wildcardObservers: [MediaObserver] {
return mediaObservers.values.filter({ $0.subject == .all })
}
// MARK: - Notifying observers
/// Notifies observers that a media item is processing/importing.
///
func processing(_ media: Media) {
notifyObserversForMedia(media, ofStateChange: .processing)
}
/// Notifies observers that a media item has begun uploading.
///
func uploading(_ media: Media, progress: Progress) {
notifyObserversForMedia(media, ofStateChange: .uploading(progress: progress))
}
/// Notifies observers that a thumbnail is ready for the media item
///
func thumbnailReady(url: URL, for media: Media) {
notifyObserversForMedia(media, ofStateChange: .thumbnailReady(url: url))
}
/// Notifies observers that a media item has ended uploading.
///
func end(_ media: Media) {
notifyObserversForMedia(media, ofStateChange: .ended)
}
/// Notifies observers that a media item has failed to upload.
///
func fail(_ error: NSError, media: Media) {
notifyObserversForMedia(media, ofStateChange: .failed(error: error))
}
/// Notifies observers that a media item is in progress.
///
func progress(_ value: Double, media: Media) {
notifyObserversForMedia(media, ofStateChange: .progress(value: value))
}
func notifyObserversForMedia(_ media: Media, ofStateChange state: MediaState) {
let originalPostIDs: [NSManagedObjectID] = coreDataStack.performQuery { context in
guard let mediaInContext = try? context.existingObject(with: media.objectID) as? Media else {
return []
}
return mediaInContext.posts?.compactMap { (object: AnyHashable) in
guard let post = object as? AbstractPost else {
return nil
}
return (post.original ?? post).objectID
} ?? []
}
queue.async {
self.observersForMedia(withObjectID: media.objectID, originalPostIDs: originalPostIDs).forEach({ observer in
DispatchQueue.main.async {
if let media = self.mainContext.object(with: media.objectID) as? Media {
observer.onUpdate(media, state)
}
}
})
}
}
/// Sync the specified blog media library.
///
/// - parameter blog: The blog from where to sync the media library from.
///
@objc func syncMedia(for blog: Blog, success: (() -> Void)? = nil, failure: ((Error) ->Void)? = nil) {
syncOperationQueue.addOperation(AsyncBlockOperation { done in
self.coreDataStack.performAndSave { context in
let service = self.mediaServiceFactory.create(context)
service.syncMediaLibrary(
for: blog,
success: {
done()
success?()
},
failure: { error in
done()
failure?(error)
}
)
}
})
}
/// This method checks the status of all media objects and updates them to the correct status if needed.
/// The main cause of wrong status is the app being killed while uploads of media are happening.
///
@objc func refreshMediaStatus() {
Media.refreshMediaStatus(using: coreDataStack)
}
}
// MARK: - MediaProgressCoordinatorDelegate
extension MediaCoordinator: MediaProgressCoordinatorDelegate {
func mediaProgressCoordinator(_ mediaProgressCoordinator: MediaProgressCoordinator, progressDidChange totalProgress: Double) {
for (mediaID, mediaProgress) in mediaProgressCoordinator.mediaInProgress {
guard let media = mediaProgressCoordinator.media(withIdentifier: mediaID) else {
continue
}
if media.remoteStatus == .pushing || media.remoteStatus == .processing {
progress(mediaProgress.fractionCompleted, media: media)
}
}
}
func mediaProgressCoordinatorDidStartUploading(_ mediaProgressCoordinator: MediaProgressCoordinator) {
}
func mediaProgressCoordinatorDidFinishUpload(_ mediaProgressCoordinator: MediaProgressCoordinator) {
// We only want to show an upload notice for uploads initiated within
// the media library.
// If the errors are causes by a missing file, we want to ignore that too.
let allFailedMediaErrorsAreMissingFilesErrors = mediaProgressCoordinator.failedMedia.allSatisfy { $0.hasMissingFileError }
let allFailedMediaHaveAssociatedPost = mediaProgressCoordinator.failedMedia.allSatisfy { $0.hasAssociatedPost() }
if mediaProgressCoordinator.failedMedia.isEmpty || (!allFailedMediaErrorsAreMissingFilesErrors && !allFailedMediaHaveAssociatedPost),
mediaProgressCoordinator == mediaLibraryProgressCoordinator || mediaProgressCoordinator.hasFailedMedia {
let model = MediaProgressCoordinatorNoticeViewModel(mediaProgressCoordinator: mediaProgressCoordinator)
if let notice = model?.notice {
ActionDispatcher.dispatch(NoticeAction.post(notice))
}
}
mediaProgressCoordinator.stopTrackingOfAllMedia()
if mediaProgressCoordinator != mediaLibraryProgressCoordinator {
removeCoordinator(mediaProgressCoordinator)
}
}
}
extension MediaCoordinator {
// Based on user logs we've collected for users, we've noticed the app sometimes
// trying to upload a Media object and failing because the underlying file has disappeared from
// `Documents` folder.
// We want to collect more data about that, so we're going to log that info to Sentry,
// and also delete the `Media` object, since there isn't really a reasonable way to recover from that failure.
func addObserverForDeletedFiles() {
addObserver({ (media, _) in
guard let mediaError = media.error,
media.hasMissingFileError else {
return
}
self.cancelUploadAndDeleteMedia(media)
WordPressAppDelegate.crashLogging?.logMessage("Deleting a media object that's failed to upload because of a missing local file. \(mediaError)")
}, for: nil)
}
}
extension Media {
var uploadID: String {
return objectID.uriRepresentation().absoluteString
}
fileprivate var hasMissingFileError: Bool {
// So this is weirdly complicated for a weird reason.
// Turns out, Core Data and Swift-y `Error`s do not play super well together, but there's some magic here involved.
// If you assing an `Error` property to a Core Data's object field, it will retain all it's Swifty-ish magic properties,
// it'll have all the enum values you expect, etc.
// However.
// Persisting the data to disk and/or reading it from a different MOC using methods like `existingObjectWithID(:_)`
// or similar, loses all that data, and the resulting error is "simplified" down to a "dumb"
// `NSError` with just a `domain` and `code` set.
// This was _not_ a fun one to track down.
// I don't want to hand-encode the Alamofire.AFError domain and/or code — they're both subject to change
// in the future, so I'm hand-creating an error here to get the domain/code out of.
let multipartEncodingFailedSampleError = AFError.multipartEncodingFailed(reason: .bodyPartFileNotReachable(at: URL(string: "https://wordpress.com")!)) as NSError
// (yes, yes, I know, unwrapped optional. but if creating a URL from this string fails, then something is probably REALLY wrong and we should bail anyway.)
// If we still have enough data to know this is a Swift Error, let's do the actual right thing here:
if let afError = error as? AFError {
guard
case .multipartEncodingFailed = afError,
case .multipartEncodingFailed(let encodingFailure) = afError else {
return false
}
switch encodingFailure {
case .bodyPartFileNotReachableWithError,
.bodyPartFileNotReachable:
return true
default:
return false
}
} else if let nsError = error as NSError?,
nsError.domain == multipartEncodingFailedSampleError.domain,
nsError.code == multipartEncodingFailedSampleError.code {
// and if we only have the NSError-level of data, let's just fall back on best-effort guess.
return true
} else if let nsError = error as NSError?,
nsError.domain == MediaServiceErrorDomain,
nsError.code == MediaServiceError.fileDoesNotExist.rawValue {
// if for some reason, the app crashed when trying to create a media object (like, for example, in this crash):
// https://github.com/wordpress-mobile/gutenberg-mobile/issues/1190
// the Media objects ends up in a malformed state, and we acutally handle that on the
// MediaService level. We need to also handle it here!
return true
}
return false
}
}