chromium/ios/chrome/browser/snapshots/model/snapshot_storage.swift

// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import UIKit

// Maximum size in number of elements that the LRU cache can hold before starting to evict elements.
let kLRUCacheMaxCapacity = 6

// Maximum size in number of elements that the LRU cache can hold before starting to evict elements
// when PinnedTabs feature is enabled.
//
// To calculate the cache size number we'll start with the assumption that currently snapshot
// preloading feature "works fine". In the reality it might not be the case for large screen devices
// such as iPad. Another assumption here is that pinned tabs feature requires on average 4 more
// snapshots to be used. Based on that kLRUCacheMaxCapacityForPinnedTabsEnabled is
// kLRUCacheMaxCapacity which "works fine" + on average 4 more snapshots needed for pinned tabs
// feature.
let kLRUCacheMaxCapacityForPinnedTabsEnabled = 10

// A class providing an in-memory and on-disk storage of tab snapshots.
// A snapshot is a full-screen image of the contents of the page at the current scroll offset and
// zoom level, used to stand in for the WKWebView if it has been purged from memory or when quickly
// switching tabs. Persists to disk on a background thread each time a snapshot changes.
@objcMembers public class SnapshotStorage: NSObject {
  // Weak type to store the observers.
  struct Weak<T: AnyObject> {
    weak var value: T?
  }

  // Cache to hold color snapshots in memory. The gray snapshots are not kept in memory at all.
  private let lruCache: SnapshotLRUCache

  // File manager to read/write images from/to the disk.
  private let fileManager: ImageFileManager

  // List of observers to be notified of changes to the snapshot storage.
  private var observers: [Weak<SnapshotStorageObserver>]

  // Designated initializer. `storageDirectoryUrl` is the file path where all images managed by this
  // SnapshotStorage are stored. `storageDirectoryUrl` is not guaranteed to exist. The contents of
  // `storageDirectoryUrl` are entirely managed by this SnapshotStorage.
  //
  // To support renaming the directory where the snapshots are stored, it is possible to pass a
  // non-empty path via `legacyDirectoryUrl`. If present, then it will be moved to
  // `storageDirectoryUrl`.
  //
  // TODO(crbug.com/40942167): Remove `legacyDirectoryUrl` when the storage for all users has been
  // migrated.
  init(storageDirectoryUrl: URL, legacyDirectoryUrl: URL?) {
    // Use the different size of LRUCache when the pinned tabs feature is enabled.
    // The pinned tabs feature is fully enabled on iPhone and disabled on iPad. The condition to
    // determine the cache size should sync with IsPinnedTabsEnabled() in
    // ios/chrome/browser/tabs/model/features.h.
    self.lruCache = SnapshotLRUCache(
      size: UIDevice.current.userInterfaceIdiom != .pad
        ? kLRUCacheMaxCapacityForPinnedTabsEnabled : kLRUCacheMaxCapacity)
    self.fileManager = ImageFileManager(
      storageDirectoryUrl: storageDirectoryUrl, legacyDirectoryUrl: legacyDirectoryUrl)
    self.observers = []

    super.init()

    NotificationCenter.default.addObserver(
      self, selector: #selector(handleLowMemory),
      name: UIApplication.didReceiveMemoryWarningNotification, object: nil)
    NotificationCenter.default.addObserver(
      self, selector: #selector(handleEnterBackground),
      name: UIApplication.didEnterBackgroundNotification, object: nil)
  }

  // Unregisters observers from Notification Center.
  deinit {
    NotificationCenter.default.removeObserver(
      self, name: UIApplication.didReceiveMemoryWarningNotification, object: nil)
    NotificationCenter.default.removeObserver(
      self, name: UIApplication.didEnterBackgroundNotification, object: nil)
  }

  // Retrieves a cached snapshot for the `snapshotID` and return it via the callback if it exists.
  // The callback is guaranteed to be called synchronously if the image is in memory. It will be
  // called asynchronously if the image is on the disk or with nil if the image is not present at
  // all.
  func retrieveImage(snapshotID: SnapshotIDWrapper, completion: @escaping (UIImage?) -> Void) {
    assert(snapshotID.valid(), "Snapshot ID should be valid")
    if let image = self.lruCache.getObject(forKey: snapshotID) {
      completion(image)
      return
    }

    self.fileManager.readImage(snapshotID: snapshotID) { (image) -> Void in
      guard let image = image else {
        completion(nil)
        return
      }
      self.lruCache.setObject(value: image, forKey: snapshotID)
      completion(image)
    }
  }

  // Retrieves a grey snapshot for `snapshotID`. If the color image is already loaded in memory,
  // the grey snapshot will be generated and the callback will be called immediately. It will be
  // called asynchronously if the color image doesn't exist in memory.
  func retrieveGreyImage(snapshotID: SnapshotIDWrapper, completion: @escaping (UIImage?) -> Void) {
    assert(snapshotID.valid(), "Snapshot ID should be valid")
    if let colorImage = self.lruCache.getObject(forKey: snapshotID) {
      completion(UiKitUtils.greyImage(colorImage))
      return
    }

    // Fallback to reading a color image from the disk when there is no color image in the cache.
    self.fileManager.readImage(snapshotID: snapshotID) { (image) -> Void in
      guard let image = image else {
        completion(nil)
        return
      }
      completion(UiKitUtils.greyImage(image))
    }
  }

  // Sets the image in both the LRU cache and the disk.
  func setImage(_ image: UIImage?, snapshotID: SnapshotIDWrapper) {
    guard let image = image, snapshotID.valid() else {
      return
    }

    lruCache.setObject(value: image, forKey: snapshotID)
    fileManager.write(image: image, snapshotID: snapshotID)

    if let cgImage = image.cgImage {
      // TODO(crbug.com/40910912): Fix the miscalculation of IOS.Snapshots.CacheSize.
      // Each image in the cache has the same resolution and hence the same size.
      let imageSizes = cgImage.bytesPerRow * cgImage.height * Int(lruCache.getCount())
      HistogramUtils.recordHistogram("IOS.Snapshots.CacheSize", withMemoryKB: imageSizes / 1024)
    }

    for observer in observers {
      observer.value?.didUpdateSnapshotStorage?(snapshotID: snapshotID)
    }
  }

  // Removes the image from both the LRU cache and the disk.
  func removeImage(snapshotID: SnapshotIDWrapper) {
    lruCache.removeObject(forKey: snapshotID)
    fileManager.removeImage(snapshotID: snapshotID)

    for weakObserver in observers {
      if let observer = weakObserver.value {
        observer.didUpdateSnapshotStorage?(snapshotID: snapshotID)
      }
    }
  }

  // Removes all images from both the LRU cahce and the disk.
  func removeAllImages() {
    lruCache.removeAllObjects()
    fileManager.removeAllImages()
  }

  // Purges the storage of snapshots that are older than `thresholdDate`. The snapshots for
  // `liveSnapshotIDs` will be kept. This will be done asynchronously.
  func purgeImagesOlderThan(thresholdDate: Date, liveSnapshotIDs: [SnapshotIDWrapper]) {
    fileManager.purgeImagesOlderThan(thresholdDate: thresholdDate, liveSnapshotIDs: liveSnapshotIDs)
  }

  // Renames snapshots with names in `oldIDs` to names in `newIDs`. It is a programmatic error if
  // the two array do not have the same length.
  func renameSnapshots(oldIDs: [String], newIDs: [SnapshotIDWrapper]) {
    assert(
      oldIDs.count == newIDs.count, "The number of old snapshot IDs and new IDs should be same")
    fileManager.renameSnapshots(oldIDs: oldIDs, newIDs: newIDs)
  }

  // Moves the on-disk snapshot from the receiver storage to the destination on-disk storage. If
  // the snapshot is also in-memory, it is moved as well.
  func migrateImage(snapshotID: SnapshotIDWrapper, destinationStorage: SnapshotStorage) {
    if let image = lruCache.getObject(forKey: snapshotID) {
      // Copy both on-disk and in-memory versions.
      destinationStorage.setImage(image, snapshotID: snapshotID)
    } else {
      // Copy on-disk.
      guard let oldPath = imagePath(snapshotID: snapshotID) else {
        return
      }
      guard let newPath = destinationStorage.imagePath(snapshotID: snapshotID) else {
        return
      }
      fileManager.copyImage(oldPath: oldPath, newPath: newPath)
    }

    // Remove the snapshot from this storage.
    removeImage(snapshotID: snapshotID)
  }

  // Adds an observer to this snapshot storage.
  func addObserver(_ observer: SnapshotStorageObserver) {
    if observers.contains(where: { $0.value === observer }) {
      return
    }
    observers.append(Weak(value: observer))
  }

  // Removes an observer from this snapshot storage.
  func removeObserver(_ observer: SnapshotStorageObserver) {
    if let index = observers.firstIndex(where: { $0.value === observer }) {
      observers.remove(at: index)
    }
  }

  // Returns the file path of the image for `snapshotID`.
  func imagePath(snapshotID: SnapshotIDWrapper) -> URL? {
    fileManager.imagePath(snapshotID: snapshotID)
  }

  // Removes all UIImages from the cache.
  func handleLowMemory() {
    lruCache.removeAllObjects()
  }

  // Removes all UIImages from the cache.
  func handleEnterBackground() {
    lruCache.removeAllObjects()
  }

  // Returns the maximum size that the cache can have.
  func lruCacheMaxSize() -> Int {
    return lruCache.maxCacheSize()
  }

  // Clears all images from the cache.
  func clearCache() {
    lruCache.removeAllObjects()
  }
}