Save GIFs Locally

This commit is contained in:
Joshua Higgins
2025-06-03 22:18:23 -04:00
parent a09e08763f
commit 8ce5adc766
8 changed files with 283 additions and 116 deletions

View File

@@ -24,7 +24,7 @@ class GIFCollectionViewCell: UICollectionViewCell {
return label return label
}() }()
private var currentTask: URLSessionDataTask? private var gifData: Data?
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
@@ -38,8 +38,7 @@ class GIFCollectionViewCell: UICollectionViewCell {
override func prepareForReuse() { override func prepareForReuse() {
super.prepareForReuse() super.prepareForReuse()
currentTask?.cancel() gifData = nil
currentTask = nil
gifPlayerView.stopAnimating() gifPlayerView.stopAnimating()
placeholderLabel.isHidden = false placeholderLabel.isHidden = false
loadingIndicator.stopAnimating() loadingIndicator.stopAnimating()
@@ -72,36 +71,32 @@ class GIFCollectionViewCell: UICollectionViewCell {
contentView.layer.borderColor = UIColor.systemGray4.cgColor contentView.layer.borderColor = UIColor.systemGray4.cgColor
} }
func configure(with urlString: String) { func configure(with gif: GIF) {
// Cancel any existing task
currentTask?.cancel()
// Reset UI // Reset UI
gifPlayerView.stopAnimating() gifPlayerView.stopAnimating()
placeholderLabel.isHidden = false placeholderLabel.isHidden = false
loadingIndicator.startAnimating() loadingIndicator.startAnimating()
guard let url = URL(string: urlString) else { // Load the GIF data from local storage
loadingIndicator.stopAnimating() DispatchQueue.global(qos: .userInitiated).async { [weak self] in
return if let gifData = GIFStorageService.shared.getGIFData(for: gif) {
}
// Load the GIF DispatchQueue.main.async {
currentTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, error in guard let self = self else { return }
DispatchQueue.main.async { self.loadingIndicator.stopAnimating()
guard let self = self else { return }
self.loadingIndicator.stopAnimating()
if let data = data, error == nil { self.gifData = gifData
self.gifPlayerView.loadGIF(from: data) self.gifPlayerView.loadGIF(from: gifData)
self.gifPlayerView.startAnimating() self.gifPlayerView.startAnimating()
self.placeholderLabel.isHidden = true self.placeholderLabel.isHidden = true
} else { }
} else {
DispatchQueue.main.async {
guard let self = self else { return }
self.loadingIndicator.stopAnimating()
self.placeholderLabel.isHidden = false self.placeholderLabel.isHidden = false
} }
} }
} }
currentTask?.resume()
} }
} }

View File

@@ -33,55 +33,47 @@ class MessagesViewController: MSMessagesAppViewController {
} }
private func sendGIF(_ gif: GIF) { private func sendGIF(_ gif: GIF) {
guard let conversation = activeConversation, guard let conversation = activeConversation else { return }
let gifURL = gif.url
else { return }
// Show a loading indicator // Show a loading indicator
let loadingAlert = UIAlertController( let loadingAlert = UIAlertController(title: "Preparing GIF", message: "Please wait...", preferredStyle: .alert)
title: "Preparing GIF", message: "Please wait...", preferredStyle: .alert) present(loadingAlert, animated: true)
present(loadingAlert, animated: true)
// Download the GIF data // Load the GIF data from local storage
GIFDownloadService.shared.downloadGIF(from: gif.urlString) { data, error in DispatchQueue.global(qos: .userInitiated).async {
DispatchQueue.main.async { let gifData = GIFStorageService.shared.getGIFData(for: gif)
// Dismiss the loading indicator
self.dismiss(animated: true) {
if let error = error {
self.showErrorAlert(error: error)
return
}
guard let gifData = data else { DispatchQueue.main.async {
self.showErrorAlert(message: "Failed to download GIF") // Dismiss the loading indicator
return self.dismiss(animated: true) {
} guard let gifData = gifData else {
self.showErrorAlert(message: "Failed to load GIF from storage")
return
}
// Create a temporary file URL for the GIF // Create a temporary file URL for the GIF
let tempDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let tempDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let tempFileURL = tempDirectoryURL.appendingPathComponent(UUID().uuidString) let tempFileURL = tempDirectoryURL.appendingPathComponent(UUID().uuidString).appendingPathExtension("gif")
.appendingPathExtension("gif")
do { do {
// Write GIF data to temporary file // Write GIF data to temporary file
try gifData.write(to: tempFileURL) try gifData.write(to: tempFileURL)
// Insert the GIF directly as a standard attachment into the message field // Insert the GIF directly as a standard attachment into the message field
conversation.insertAttachment(tempFileURL, withAlternateFilename: "animated.gif") { conversation.insertAttachment(tempFileURL, withAlternateFilename: "animated.gif") { error in
error in if let error = error {
if let error = error { self.showErrorAlert(error: error)
self.showErrorAlert(error: error) } else {
} else { // Successfully inserted the attachment
// Successfully inserted the attachment self.requestPresentationStyle(.compact)
self.requestPresentationStyle(.compact) }
}
} catch {
self.showErrorAlert(error: error)
}
} }
}
} catch {
self.showErrorAlert(error: error)
} }
}
} }
}
} }
private func showErrorAlert(error: Error? = nil, message: String? = nil) { private func showErrorAlert(error: Error? = nil, message: String? = nil) {

View File

@@ -3,16 +3,18 @@ import UIKit
struct GIF: Codable, Identifiable, Equatable { struct GIF: Codable, Identifiable, Equatable {
let id: UUID let id: UUID
let urlString: String let localFilePath: String
let createdAt: Date let createdAt: Date
let originalURL: String
var url: URL? { var fileURL: URL? {
return URL(string: urlString) return URL(fileURLWithPath: localFilePath)
} }
init(urlString: String) { init(localFilePath: String, originalURL: String) {
self.id = UUID() self.id = UUID()
self.urlString = urlString self.localFilePath = localFilePath
self.originalURL = originalURL
self.createdAt = Date() self.createdAt = Date()
} }

View File

@@ -1,19 +1,19 @@
import Foundation import Foundation
import UIKit import UIKit
class GIFDownloadService { class DownloadService {
static let shared = GIFDownloadService() static let shared = DownloadService()
private let cache = NSCache<NSString, NSData>() private let cache = NSCache<NSString, NSData>()
private var activeTasks: [URL: URLSessionDataTask] = [:] private var activeTasks: [URL: URLSessionDataTask] = [:]
private init() { private init() {
cache.totalCostLimit = 100 * 1024 * 1024 // 100 MB cache limit cache.totalCostLimit = 50 * 1024 * 1024 // 50 MB cache limit
} }
func downloadGIF(from urlString: String, completion: @escaping (Data?, Error?) -> Void) { func downloadGIF(from urlString: String, completion: @escaping (Data?, Error?) -> Void) {
guard let url = URL(string: urlString) else { guard let url = URL(string: urlString) else {
completion(nil, NSError(domain: "GIFDownloadService", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"])) completion(nil, NSError(domain: "DownloadService", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"]))
return return
} }
@@ -35,7 +35,7 @@ class GIFDownloadService {
guard let self = self, error == nil, let data = data else { guard let self = self, error == nil, let data = data else {
DispatchQueue.main.async { DispatchQueue.main.async {
completion(nil, error ?? NSError(domain: "GIFDownloadService", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to download GIF"])) completion(nil, error ?? NSError(domain: "DownloadService", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to download GIF"]))
} }
return return
} }

View File

@@ -0,0 +1,132 @@
import Foundation
import UIKit
class GIFFileManager {
static let shared = GIFFileManager()
private init() {
createGIFsDirectoryIfNeeded()
}
// MARK: - File Storage
private var documentsDirectory: URL {
let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
return paths[0]
}
private var gifsDirectory: URL {
return documentsDirectory.appendingPathComponent("SavedGIFs", isDirectory: true)
}
private func createGIFsDirectoryIfNeeded() {
let fileManager = FileManager.default
if !fileManager.fileExists(atPath: gifsDirectory.path) {
do {
try fileManager.createDirectory(at: gifsDirectory, withIntermediateDirectories: true)
} catch {
print("Error creating GIFs directory: \(error)")
}
}
}
func storeGIF(data: Data, fromURL urlString: String) -> String? {
// Create a unique filename based on the URL hash and timestamp
let urlHash = urlString.hashValue
let timestamp = Int(Date().timeIntervalSince1970)
let filename = "gif_\(urlHash)_\(timestamp).gif"
let fileURL = gifsDirectory.appendingPathComponent(filename)
do {
try data.write(to: fileURL)
return fileURL.path
} catch {
print("Error saving GIF to disk: \(error)")
return nil
}
}
func loadGIFData(from localPath: String) -> Data? {
let url = URL(fileURLWithPath: localPath)
do {
let data = try Data(contentsOf: url)
return data
} catch {
print("Error loading GIF from disk: \(error)")
return nil
}
}
func deleteGIF(at localPath: String) -> Bool {
let fileManager = FileManager.default
do {
try fileManager.removeItem(atPath: localPath)
return true
} catch {
print("Error deleting GIF from disk: \(error)")
return false
}
}
func fileExists(at localPath: String) -> Bool {
return FileManager.default.fileExists(atPath: localPath)
}
func getAllGIFsSize() -> Int64 {
let fileManager = FileManager.default
let enumerator = fileManager.enumerator(at: gifsDirectory, includingPropertiesForKeys: [.fileSizeKey])
var totalSize: Int64 = 0
while let fileURL = enumerator?.nextObject() as? URL {
do {
let attributes = try fileURL.resourceValues(forKeys: [.fileSizeKey])
if let fileSize = attributes.fileSize {
totalSize += Int64(fileSize)
}
} catch {
print("Error calculating file size: \(error)")
}
}
return totalSize
}
// Clean up old GIFs if storage exceeds 100 MB
func performStorageCleanupIfNeeded() {
let maxStorageSize: Int64 = 100 * 1024 * 1024 // 100 MB
if getAllGIFsSize() > maxStorageSize {
cleanupOldGIFs()
}
}
private func cleanupOldGIFs() {
let fileManager = FileManager.default
do {
// Get all files and their creation dates
let fileURLs = try fileManager.contentsOfDirectory(at: gifsDirectory, includingPropertiesForKeys: [.creationDateKey])
// Sort by creation date
let sortedFiles = try fileURLs.sorted {
let date1 = try $0.resourceValues(forKeys: [.creationDateKey]).creationDate ?? Date.distantPast
let date2 = try $1.resourceValues(forKeys: [.creationDateKey]).creationDate ?? Date.distantPast
return date1 < date2
}
// Delete the oldest 30% of files
let filesToDelete = Int(Double(sortedFiles.count) * 0.3)
for i in 0..<min(filesToDelete, sortedFiles.count) {
try fileManager.removeItem(at: sortedFiles[i])
}
} catch {
print("Error cleaning up old GIFs: \(error)")
}
}
}

View File

@@ -13,14 +13,28 @@ class GIFStorageService {
} }
} }
func saveGIF(_ gif: GIF) { func saveGIF(data: Data, fromURL urlString: String, completion: @escaping (GIF?) -> Void) {
// First store the GIF data to disk
guard let localPath = GIFFileManager.shared.storeGIF(data: data, fromURL: urlString) else {
completion(nil)
return
}
// Create and save the GIF model
let gif = GIF(localFilePath: localPath, originalURL: urlString)
var savedGIFs = fetchGIFs() var savedGIFs = fetchGIFs()
// Don't save duplicate URLs // Don't save duplicates of the same URL
if !savedGIFs.contains(where: { $0.urlString == gif.urlString }) { if !savedGIFs.contains(where: { $0.originalURL == urlString }) {
savedGIFs.append(gif) savedGIFs.append(gif)
saveToUserDefaults(gifs: savedGIFs) saveToUserDefaults(gifs: savedGIFs)
} }
// Perform cleanup if needed
GIFFileManager.shared.performStorageCleanupIfNeeded()
completion(gif)
} }
func fetchGIFs() -> [GIF] { func fetchGIFs() -> [GIF] {
@@ -29,16 +43,38 @@ class GIFStorageService {
return [] return []
} }
return gifs.sorted(by: { $0.createdAt > $1.createdAt }) // Filter out any GIFs whose files no longer exist
let validGIFs = gifs.filter { GIFFileManager.shared.fileExists(at: $0.localFilePath) }
// If we filtered any out, save the updated list
if validGIFs.count != gifs.count {
saveToUserDefaults(gifs: validGIFs)
}
return validGIFs.sorted(by: { $0.createdAt > $1.createdAt })
} }
func deleteGIF(with id: UUID) { func deleteGIF(with id: UUID) {
var savedGIFs = fetchGIFs() var savedGIFs = fetchGIFs()
savedGIFs.removeAll(where: { $0.id == id })
saveToUserDefaults(gifs: savedGIFs) // Find the GIF to delete
if let gifToDelete = savedGIFs.first(where: { $0.id == id }) {
// Delete the file from storage
GIFFileManager.shared.deleteGIF(at: gifToDelete.localFilePath)
// Remove from the list
savedGIFs.removeAll(where: { $0.id == id })
saveToUserDefaults(gifs: savedGIFs)
}
} }
func clearAllGIFs() { func clearAllGIFs() {
// Delete all GIF files
fetchGIFs().forEach { gif in
GIFFileManager.shared.deleteGIF(at: gif.localFilePath)
}
// Clear the list
saveToUserDefaults(gifs: []) saveToUserDefaults(gifs: [])
} }
@@ -46,4 +82,8 @@ class GIFStorageService {
guard let data = try? JSONEncoder().encode(gifs) else { return } guard let data = try? JSONEncoder().encode(gifs) else { return }
userDefaults?.set(data, forKey: savedGIFsKey) userDefaults?.set(data, forKey: savedGIFsKey)
} }
func getGIFData(for gif: GIF) -> Data? {
return GIFFileManager.shared.loadGIFData(from: gif.localFilePath)
}
} }

View File

@@ -22,8 +22,6 @@ class AddGIFViewController: UIViewController {
return textField return textField
}() }()
private let previewGIFPlayer: GIFPlayerView = { private let previewGIFPlayer: GIFPlayerView = {
let player = GIFPlayerView() let player = GIFPlayerView()
player.clipsToBounds = true player.clipsToBounds = true
@@ -56,8 +54,9 @@ class AddGIFViewController: UIViewController {
return button return button
}() }()
private var currentTask: URLSessionDataTask? private var currentTask: Any?
var onSaveGIF: ((GIF) -> Void)? private var downloadedGIFData: Data?
var onSaveGIF: ((String, Data) -> Void)?
var onCancel: (() -> Void)? var onCancel: (() -> Void)?
override func viewDidLoad() { override func viewDidLoad() {
@@ -113,12 +112,12 @@ class AddGIFViewController: UIViewController {
} }
private func setupActions() { private func setupActions() {
urlTextField.delegate = self urlTextField.delegate = self
urlTextField.addTarget(self, action: #selector(urlTextDidChange), for: .editingChanged) urlTextField.addTarget(self, action: #selector(urlTextDidChange), for: .editingChanged)
saveButton.addTarget(self, action: #selector(saveButtonTapped), for: .touchUpInside) saveButton.addTarget(self, action: #selector(saveButtonTapped), for: .touchUpInside)
cancelButton.addTarget(self, action: #selector(cancelButtonTapped), for: .touchUpInside) cancelButton.addTarget(self, action: #selector(cancelButtonTapped), for: .touchUpInside)
} }
@objc private func urlTextDidChange() { @objc private func urlTextDidChange() {
@@ -132,10 +131,13 @@ class AddGIFViewController: UIViewController {
} }
private func loadGIFPreview(from urlString: String) { private func loadGIFPreview(from urlString: String) {
// Cancel any existing task // Cancel any existing task if needed
currentTask?.cancel() if let task = currentTask as? URLSessionTask {
task.cancel()
}
currentTask = nil
guard let url = URL(string: urlString) else { guard URL(string: urlString) != nil else {
saveButton.isEnabled = false saveButton.isEnabled = false
return return
} }
@@ -143,33 +145,34 @@ class AddGIFViewController: UIViewController {
loadingIndicator.startAnimating() loadingIndicator.startAnimating()
previewGIFPlayer.stopAnimating() previewGIFPlayer.stopAnimating()
currentTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, error in DownloadService.shared.downloadGIF(from: urlString) { [weak self] data, error in
DispatchQueue.main.async { DispatchQueue.main.async {
guard let self = self else { return } guard let self = self else { return }
self.loadingIndicator.stopAnimating() self.loadingIndicator.stopAnimating()
if let data = data, error == nil { if let data = data, error == nil {
self.downloadedGIFData = data
self.previewGIFPlayer.loadGIF(from: data) self.previewGIFPlayer.loadGIF(from: data)
self.previewGIFPlayer.startAnimating() self.previewGIFPlayer.startAnimating()
self.saveButton.isEnabled = true self.saveButton.isEnabled = true
} else { } else {
self.downloadedGIFData = nil
self.previewGIFPlayer.stopAnimating() self.previewGIFPlayer.stopAnimating()
self.saveButton.isEnabled = false self.saveButton.isEnabled = false
} }
} }
} }
currentTask?.resume()
} }
@objc private func saveButtonTapped() { @objc private func saveButtonTapped() {
guard let urlString = urlTextField.text, !urlString.isEmpty else { return } guard let urlString = urlTextField.text,
!urlString.isEmpty,
let gifData = downloadedGIFData
else { return }
let gif = GIF(urlString: urlString) onSaveGIF?(urlString, gifData)
dismiss(animated: true)
onSaveGIF?(gif)
dismiss(animated: true)
} }
@objc private func cancelButtonTapped() { @objc private func cancelButtonTapped() {
@@ -179,8 +182,8 @@ class AddGIFViewController: UIViewController {
} }
extension AddGIFViewController: UITextFieldDelegate { extension AddGIFViewController: UITextFieldDelegate {
func textFieldShouldReturn(_ textField: UITextField) -> Bool { func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder() textField.resignFirstResponder()
return true return true
} }
} }

View File

@@ -99,9 +99,12 @@ class GIFCollectionViewController: UIViewController {
@objc private func addButtonTapped() { @objc private func addButtonTapped() {
let addGIFVC = AddGIFViewController() let addGIFVC = AddGIFViewController()
addGIFVC.onSaveGIF = { [weak self] gif in addGIFVC.onSaveGIF = { [weak self] urlString, gifData in
GIFStorageService.shared.saveGIF(gif) GIFStorageService.shared.saveGIF(data: gifData, fromURL: urlString) { _ in
self?.loadGIFs() DispatchQueue.main.async {
self?.loadGIFs()
}
}
} }
addGIFVC.onCancel = { [weak self] in addGIFVC.onCancel = { [weak self] in
self?.dismiss(animated: true) self?.dismiss(animated: true)
@@ -124,7 +127,7 @@ extension GIFCollectionViewController: UICollectionViewDelegate, UICollectionVie
} }
let gif = gifs[indexPath.item] let gif = gifs[indexPath.item]
cell.configure(with: gif.urlString) cell.configure(with: gif)
return cell return cell
} }