Skip to main content

Blink/Photos/PhotosLibrary.swift

1import Foundation
2import Photos
4/// Manage most of the interactions with the Photos Library.
5///
6/// This includes loading all the asset data, and reacting to changes
7/// in the Photos Library (both external and triggered by Blink).
8///
9class PhotosLibrary: NSObject, ObservableObject, PHPhotoLibraryChangeObserver {
11 @Published var isPhotoLibraryAuthorized = false
13 @Published var assets: PHFetchResult<PHAsset> = PHFetchResult()
15 @Published var approvedAssets: PHFetchResult<PHAsset> = PHFetchResult()
16 @Published var rejectedAssets: PHFetchResult<PHAsset> = PHFetchResult()
17 @Published var needsActionAssets: PHFetchResult<PHAsset> = PHFetchResult()
19 // These lists/sets allow us to do some fast lookups for getting the
20 // state of an image, without going back to the Photos database.
21 // Individual database calls are fast; 25,000 if you need to retrieve
22 // all the thumbnails adds noticeable latency.
23 //
24 // 99% of the time, these match the PHFetchResult data; they differ when
25 // somebody has just modified state (e.g. reviewed a photo as "approved").
26 // We can update the internal set as soon as the PHChangeRequest completes,
27 // without waiting to get the update back from the Photos Library.
28 // That might not seem like much, but the latency is enough to feel
29 // noticeable, and tracking our own copy of that state makes the UI
30 // feel much more responsive.
31 @Published var assetIdentifiers: [String] = []
33 private var approvedAssetIdentifiers: Set<String> = Set()
34 private var rejectedAssetIdentifiers: Set<String> = Set()
35 private var needsActionAssetIdentifiers: Set<String> = Set()
37 private var favoriteAssetIdentifiers: Set<String> = Set()
39 // We publish the latest changes we detect from the Photos library.
40 //
41 // Views can subscribe to updates with
42 //
43 // ```swift
44 // .onChange(of: photosLibrary.latestChangeDetails, perform: { lastChangeDetails in
45 // ...
46 // }
47 // ```
48 //
49 // and then access the individual properties to work out how to rearrange the
50 // UI to preserve the user's focused position (if possible).
51 //
52 // See https://developer.apple.com/documentation/photokit/phfetchresultchangedetails/1613898-enumeratemoves
53 @Published var latestChangeDetails: PHFetchResultChangeDetails<PHAsset>? = nil
55 private lazy var approved = getAlbum(withName: "Approved")
56 private lazy var rejected = getAlbum(withName: "Rejected")
57 private lazy var needsAction = getAlbum(withName: "Needs Action")
59 override init() {
60 super.init()
61 PHPhotoLibrary.shared().register(self)
62 getInitialData()
63 }
65 /// Get the initial batch of data from the Photos Library when the app starts.
66 ///
67 /// This is populating all the cached data structures.
68 ///
69 /// You may see this method called twice, if you're running the app for the first time:
70 ///
71 /// - When the app initially starts, we don't have permission to read the user's
72 /// Photos Library. This method runs pretty quickly, because we skip fetching
73 /// anything from the database -- it'll appear empty to us.
74 ///
75 /// - After the user grants permission, we'll call this method a second time, when
76 /// we can actually get all the data.
77 ///
78 private func getInitialData() {
79 DispatchQueue.main.async {
80 var timer = Timer()
82 let options = PHFetchOptions()
83 options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
85 self.isPhotoLibraryAuthorized = PHPhotoLibrary.authorizationStatus() == .authorized
87 if (self.isPhotoLibraryAuthorized) {
88 self.assets = PHAsset.fetchAssets(with: PHAssetMediaType.image, options: options)
90 self.approvedAssets = PHAsset.fetchAssets(in: self.approved, options: nil)
91 self.rejectedAssets = PHAsset.fetchAssets(in: self.rejected, options: nil)
92 self.needsActionAssets = PHAsset.fetchAssets(in: self.needsAction, options: nil)
94 self.regenerateAssetIdentifiers()
96 self.approvedAssetIdentifiers = getSetOfIdentifiers(fetchResult: self.approvedAssets)
97 self.rejectedAssetIdentifiers = getSetOfIdentifiers(fetchResult: self.rejectedAssets)
98 self.needsActionAssetIdentifiers = getSetOfIdentifiers(fetchResult: self.needsActionAssets)
99 }
101 timer.printTime("get initial Photos data (isPhotoLibraryAuthorized = \(self.isPhotoLibraryAuthorized))")
102 }
103 }
105 /// React to changes from the Photos Library.
106 ///
107 /// The PhotoKit APIs give us a bunch of information about deltas and updates,
108 /// so we don't need to reload all the information from scratch -- we can apply
109 /// partial updates to our local data.
110 ///
111 /// Note: this method is carefully tuned to balance accuracy and speed; we always
112 /// want to have the right data from Photos, but it can add noticeable latency
113 /// to UI updates if it's inefficient.
114 ///
115 /// See https://developer.apple.com/documentation/photokit/phphotolibrarychangeobserver
116 ///
117 func photoLibraryDidChange(_ changeInstance: PHChange) {
118 // If we've just received permission to read the user's Photos Library, go
119 // ahead and populate all the initial data structures.
120 if !self.isPhotoLibraryAuthorized && PHPhotoLibrary.authorizationStatus() == .authorized {
121 getInitialData()
123 // This is wrapped in an async dispatch to fix a warning from Xcode:
124 //
125 // Publishing changes from background threads is not allowed; make sure
126 // to publish values from the main thread (via operators like receive(on:))
127 // on model updates.
128 //
129 DispatchQueue.main.async {
130 self.isPhotoLibraryAuthorized = PHPhotoLibrary.authorizationStatus() == .authorized
131 }
133 return
134 }
136 DispatchQueue.main.async {
137 var timer = Timer()
139 if let assetsChangeDetails = changeInstance.changeDetails(for: self.assets) {
140 self.assets = assetsChangeDetails.fetchResultAfterChanges
142 assetsChangeDetails.changedObjects.forEach { asset in
143 // Flush the cached thumbnail/full-sized image for the asset; the
144 // external edit may have been modifying the image.
145 //
146 // TODO: This only updates the full-size image in Blink, not the
147 // thumbnail. What's up with that?
148 self.thumbnailCache.removeValue(forKey: asset)
149 self.fullSizeImageCache.removeValue(forKey: asset)
151 if asset.isFavorite {
152 self.favoriteAssetIdentifiers.insert(asset.localIdentifier)
153 } else {
154 self.favoriteAssetIdentifiers.remove(asset.localIdentifier)
155 }
156 }
158 if assetsChangeDetails.hasMoves || !assetsChangeDetails.removedObjects.isEmpty || !assetsChangeDetails.insertedObjects.isEmpty {
159 self.regenerateAssetIdentifiers()
160 }
162 self.latestChangeDetails = assetsChangeDetails
163 }
165 if let approvedChangeDetails = changeInstance.changeDetails(for: self.approvedAssets) {
166 self.approvedAssets = approvedChangeDetails.fetchResultAfterChanges
168 approvedChangeDetails.insertedObjects.forEach { asset in
169 self.approvedAssetIdentifiers.insert(asset.localIdentifier)
170 }
172 approvedChangeDetails.removedObjects.forEach { asset in
173 self.approvedAssetIdentifiers.remove(asset.localIdentifier)
174 }
175 }
177 if let rejectedChangeDetails = changeInstance.changeDetails(for: self.rejectedAssets) {
178 self.rejectedAssets = rejectedChangeDetails.fetchResultAfterChanges
180 rejectedChangeDetails.insertedObjects.forEach { asset in
181 self.rejectedAssetIdentifiers.insert(asset.localIdentifier)
182 }
184 rejectedChangeDetails.removedObjects.forEach { asset in
185 self.rejectedAssetIdentifiers.remove(asset.localIdentifier)
186 }
187 }
189 if let needsActionChangeDetails = changeInstance.changeDetails(for: self.needsActionAssets) {
190 self.needsActionAssets = needsActionChangeDetails.fetchResultAfterChanges
192 needsActionChangeDetails.insertedObjects.forEach { asset in
193 self.needsActionAssetIdentifiers.insert(asset.localIdentifier)
194 }
196 needsActionChangeDetails.removedObjects.forEach { asset in
197 self.needsActionAssetIdentifiers.remove(asset.localIdentifier)
198 }
199 }
201 timer.printTime("process change to Photos data")
203 self.isPhotoLibraryAuthorized = PHPhotoLibrary.authorizationStatus() == .authorized
204 }
205 }
207 /// Retrieve an asset at a particular position.
208 ///
209 /// Just a convenience wrapper around PHFetchResult.object(at: Int).
210 ///
211 func asset(at index: Int) -> PHAsset {
212 assets.object(at: index)
213 }
215 /// Get the review state of a given asset.
216 ///
217 /// These methods are called repeatedly on every view (when we get the
218 /// state of thumbnails), so they need to be *fast*.
219 ///
220 /// This is why we cache the list of rejected/needs action/approved assets --
221 /// to make this method fast and performant.
222 ///
223 /// Note: it's possibly for an asset to be in multiple albums if the user
224 /// fiddles with it, so we show the "most destructive" state first -- the
225 /// state that might cause data loss if the user deletes all their rejected
226 /// images. If they toggle the state in the app, we'll fix it.
227 ///
228 /// TODO: Log a warning here? Resolve somehow?
229 func state(of asset: PHAsset) -> ReviewState? {
230 if self.rejectedAssets.contains(asset) {
231 return .Rejected
232 }
234 if self.needsActionAssets.contains(asset) {
235 return .NeedsAction
236 }
238 if self.approvedAssets.contains(asset) {
239 return .Approved
240 }
242 return nil
243 }
245 func state(ofAssetAtIndex index: Int) -> ReviewState? {
246 state(of: asset(at: index))
247 }
249 func state(ofLocalIdentifier localIdentifier: String) -> ReviewState? {
250 if self.rejectedAssetIdentifiers.contains(localIdentifier) {
251 return .Rejected
252 }
254 if self.needsActionAssetIdentifiers.contains(localIdentifier) {
255 return .NeedsAction
256 }
258 if self.approvedAssetIdentifiers.contains(localIdentifier) {
259 return .Approved
260 }
262 return nil
263 }
265 /// Set the review state of an asset.
266 ///
267 /// This will record the change in the Photos Library and update any internal
268 /// data structures.
269 ///
270 func setState(ofAsset asset: PHAsset, to newState: ReviewState) -> Void {
271 let existingState = self.state(of: asset)
273 try! PHPhotoLibrary.shared().performChangesAndWait {
274 // The first condition is a combination of two:
275 //
276 // -- the photo is already approved and you hit the "approve" hotkey,
277 // -- so un-approve it
278 // state == .Approved && e.characters == "1"
279 //
280 // -- the photo is already approved and you selected a different review
281 // -- state, so unapprove it
282 // state == .Approved && e.characters != "1"
283 //
284 // We can optimise it into a single case, but it does make sense!
285 //
286 // Similar logic applies for all three conditions.
287 if existingState == .Approved {
288 asset.remove(fromAlbum: self.approved)
289 } else if newState == .Approved {
290 asset.add(toAlbum: self.approved)
291 }
293 if existingState == .Rejected {
294 asset.remove(fromAlbum: self.rejected)
295 } else if newState == .Rejected {
296 asset.add(toAlbum: self.rejected)
297 }
299 if existingState == .NeedsAction {
300 asset.remove(fromAlbum: self.needsAction)
301 } else if newState == .NeedsAction {
302 asset.add(toAlbum: self.needsAction)
303 }
304 }
306 if existingState == .Approved {
307 self.approvedAssetIdentifiers.remove(asset.localIdentifier)
308 } else if newState == .Approved {
309 self.approvedAssetIdentifiers.insert(asset.localIdentifier)
310 }
312 if existingState == .Rejected {
313 self.rejectedAssetIdentifiers.remove(asset.localIdentifier)
314 } else if newState == .Rejected {
315 self.rejectedAssetIdentifiers.insert(asset.localIdentifier)
316 }
318 if existingState == .NeedsAction {
319 self.needsActionAssetIdentifiers.remove(asset.localIdentifier)
320 } else if newState == .NeedsAction {
321 self.needsActionAssetIdentifiers.insert(asset.localIdentifier)
322 }
323 }
325 /// Returns true if this asset is a favorite, false otherwise.
326 func isFavorite(localIdentifier: String) -> Bool {
327 self.favoriteAssetIdentifiers.contains(localIdentifier)
328 }
330 // Implements a basic cache for thumbnail images.
331 //
332 // Thumbnail images are small and easily reused; I've put them here because
333 // we already pass this class around as a shared @EnvironmentObject.
334 //
335 // For some reason SwiftUI insists on trying to recreate all the thumbnail
336 // views when you step between images -- I think there's probably a way to
337 // have it cache the views rather than me doing it manually, but I'm not
338 // smart enough to debug that. If I don't cache it, there's a "flash" as
339 // it reloads the thumbnails every time.
340 //
341 // TODO: Investigate the SwiftUI caching behaviour.
342 //
343 // Note: the size of both this and the following cache are designed to balance
344 // memory usage and performance. Everything on the screen and just off it
345 // should be kept in cache, so I can e.g. switch between all the variants
346 // of a single shot, but I don't need more than that.
347 //
348 // On my M2 MacBook Air, these numbers mean the app peaks at ~250MB of memory,
349 // which seems pretty reasonable.
350 private var thumbnailCache = LRUCache<PHAsset, PHAssetImage>(withMaxSize: 500)
352 func getThumbnail(for asset: PHAsset) -> PHAssetImage {
353 if thumbnailCache[asset] == nil {
354 let newImage = PHAssetImage(
355 asset,
356 size: CGSize(width: 70, height: 70),
357 deliveryMode: .opportunistic
358 )
360 thumbnailCache[asset] = newImage
361 }
363 return thumbnailCache[asset]!
364 }
366 // Implement a similar cache for full-sized images.
367 //
368 // This is to avoid having to rebuild the PHAssetImage every time --
369 // which causes a brief "pop" as it starts by loading the low-res fuzzy image,
370 // then the high-res image pops in a second or so later.
371 //
372 // TODO: Surely it should be possible to make SwiftUI cache views like
373 // this for us?
374 private var fullSizeImageCache = LRUCache<PHAsset, PHAssetImage>(withMaxSize: 10)
376 func getFullSizedImage(for asset: PHAsset) -> PHAssetImage {
377 if fullSizeImageCache[asset] == nil {
378 let newImage = PHAssetImage(
379 asset,
380 size: PHImageManagerMaximumSize,
381 deliveryMode: .opportunistic
382 )
384 fullSizeImageCache[asset] = newImage
385 }
387 return fullSizeImageCache[asset]!
388 }
390 private func regenerateAssetIdentifiers() -> Void {
391 var assetIdentifiers: [String] = []
392 var favoriteAssetIdentifiers: Set<String> = Set()
394 self.assets.enumerateObjects { asset, _, _ in
395 assetIdentifiers.append(asset.localIdentifier)
397 if asset.isFavorite {
398 favoriteAssetIdentifiers.insert(asset.localIdentifier)
399 }
400 }
402 self.assetIdentifiers = assetIdentifiers
403 self.favoriteAssetIdentifiers = favoriteAssetIdentifiers
404 }
407func getSetOfIdentifiers(fetchResult: PHFetchResult<PHAsset>) -> Set<String> {
408 var result: Set<String> = Set()
410 fetchResult.enumerateObjects { asset, _, _ in
411 result.insert(asset.localIdentifier)
412 }
414 return result