element-ios/Riot/Managers/URLPreviews/Core Data/URLPreviewStore.swift

169 lines
6.8 KiB
Swift

//
// Copyright 2021-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import CoreData
/// A cache for URL previews backed by Core Data.
class URLPreviewStore {
// MARK: - Properties
/// The Core Data container for persisting the cache to disk.
private let container: NSPersistentContainer
/// The Core Data context used to store and load data on.
private var context: NSManagedObjectContext {
container.viewContext
}
/// A time interval that represents how long an item in the cache is valid for.
private let dataValidityTime: TimeInterval = 60 * 60 * 24
/// The oldest `creationDate` allowed for valid data.
private var expiryDate: Date {
Date().addingTimeInterval(-dataValidityTime)
}
// MARK: - Lifecycle
/// Create a URLPreview Cache optionally storing the data in memory.
/// - Parameter inMemory: Whether to store the data in memory.
init(inMemory: Bool = false) {
// Register the transformer for the `image` field.
ValueTransformer.setValueTransformer(URLPreviewImageTransformer(), forName: .urlPreviewImageTransformer)
// Create the container, updating it's path if storing the data in memory.
container = NSPersistentContainer(name: "URLPreviewStore")
if inMemory {
if let storeDescription = container.persistentStoreDescriptions.first {
storeDescription.url = CoreDataHelper.inMemoryURL
} else {
MXLog.error("[URLPreviewStore] persistentStoreDescription not found.")
}
}
// Load the persistent stores into the container
container.loadPersistentStores { storeDescription, error in
if let error = error {
MXLog.error("[URLPreviewStore] Core Data container", context: error)
}
if let url = storeDescription.url {
do {
try FileManager.default.excludeItemFromBackup(at: url)
} catch {
MXLog.error("[URLPreviewStore] Cannot exclude Core Data from backup", context: error)
}
}
}
}
// MARK: - Public
/// Cache a preview in the store. If a preview already exists with the same URL it will be updated from the new preview.
/// - Parameter preview: The preview to add to the store.
/// - Parameter date: Optional: The date the preview was generated. When nil, the current date is assigned.
func cache(_ preview: URLPreviewData, generatedOn generationDate: Date? = nil) {
// Create a fetch request for an existing preview.
let request: NSFetchRequest<URLPreviewDataMO> = URLPreviewDataMO.fetchRequest()
request.predicate = NSPredicate(format: "url == %@", preview.url as NSURL)
// Use the custom date if supplied (currently this is for testing purposes)
let date = generationDate ?? Date()
// Update existing data if found otherwise create new data.
if let cachedPreview = try? context.fetch(request).first {
cachedPreview.update(from: preview, on: date)
} else {
_ = URLPreviewDataMO(context: context, preview: preview, creationDate: date)
}
save()
}
/// Fetches the preview from the cache for the supplied URL. If a preview doesn't exist or
/// if the preview is older than the ``dataValidityTime`` the returned value will be nil.
/// - Parameter url: The URL to fetch the preview for.
/// - Returns: The preview if found, otherwise nil.
func preview(for url: URL, and event: MXEvent) -> URLPreviewData? {
// Create a request for the url excluding any expired items
let request: NSFetchRequest<URLPreviewDataMO> = URLPreviewDataMO.fetchRequest()
request.predicate = NSCompoundPredicate(type: .and, subpredicates: [
NSPredicate(format: "url == %@", url as NSURL),
NSPredicate(format: "creationDate > %@", expiryDate as NSDate)
])
// Fetch the request, returning nil if nothing was found
guard
let cachedPreview = try? context.fetch(request).first
else { return nil }
// Convert and return
return cachedPreview.preview(for: event)
}
/// Returns the number of URL previews cached in the store.
func cacheCount() -> Int {
let request: NSFetchRequest<NSFetchRequestResult> = URLPreviewDataMO.fetchRequest()
return (try? context.count(for: request)) ?? 0
}
/// Removes any expired cache data from the store.
func removeExpiredItems() {
let request: NSFetchRequest<NSFetchRequestResult> = URLPreviewDataMO.fetchRequest()
request.predicate = NSPredicate(format: "creationDate < %@", expiryDate as NSDate)
do {
try context.execute(NSBatchDeleteRequest(fetchRequest: request))
} catch {
MXLog.error("[URLPreviewStore] Error executing batch delete request", context: error)
}
}
/// Deletes all cache data and all closed previews from the store.
func deleteAll() {
do {
_ = try context.execute(NSBatchDeleteRequest(fetchRequest: URLPreviewDataMO.fetchRequest()))
_ = try context.execute(NSBatchDeleteRequest(fetchRequest: URLPreviewUserDataMO.fetchRequest()))
} catch {
MXLog.error("[URLPreviewStore] Error executing batch delete request", context: error)
}
}
/// Store the dismissal of a preview from the event with `eventId` and `roomId`.
func closePreview(for eventId: String, in roomId: String) {
_ = URLPreviewUserDataMO(context: context, eventID: eventId, roomID: roomId, dismissed: true)
save()
}
/// Whether a preview for an event with the given `eventId` and `roomId` has been closed or not.
func hasClosedPreview(for eventId: String, in roomId: String) -> Bool {
// Create a request for the url excluding any expired items
let request: NSFetchRequest<URLPreviewUserDataMO> = URLPreviewUserDataMO.fetchRequest()
request.predicate = NSCompoundPredicate(type: .and, subpredicates: [
NSPredicate(format: "eventID == %@", eventId),
NSPredicate(format: "roomID == %@", roomId),
NSPredicate(format: "dismissed == true")
])
return (try? context.count(for: request)) ?? 0 > 0
}
// MARK: - Private
/// Saves any changes that are found on the context
private func save() {
guard context.hasChanges else { return }
do {
try context.save()
} catch {
MXLog.error("[URLPreviewStore] Error saving changes", context: error)
}
}
}