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

View File

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

View File

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

View File

@@ -1,19 +1,19 @@
import Foundation
import UIKit
class GIFDownloadService {
static let shared = GIFDownloadService()
class DownloadService {
static let shared = DownloadService()
private let cache = NSCache<NSString, NSData>()
private var activeTasks: [URL: URLSessionDataTask] = [:]
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) {
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
}
@@ -35,7 +35,7 @@ class GIFDownloadService {
guard let self = self, error == nil, let data = data else {
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
}

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()
// Don't save duplicate URLs
if !savedGIFs.contains(where: { $0.urlString == gif.urlString }) {
// Don't save duplicates of the same URL
if !savedGIFs.contains(where: { $0.originalURL == urlString }) {
savedGIFs.append(gif)
saveToUserDefaults(gifs: savedGIFs)
}
// Perform cleanup if needed
GIFFileManager.shared.performStorageCleanupIfNeeded()
completion(gif)
}
func fetchGIFs() -> [GIF] {
@@ -29,16 +43,38 @@ class GIFStorageService {
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) {
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() {
// Delete all GIF files
fetchGIFs().forEach { gif in
GIFFileManager.shared.deleteGIF(at: gif.localFilePath)
}
// Clear the list
saveToUserDefaults(gifs: [])
}
@@ -46,4 +82,8 @@ class GIFStorageService {
guard let data = try? JSONEncoder().encode(gifs) else { return }
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
}()
private let previewGIFPlayer: GIFPlayerView = {
let player = GIFPlayerView()
player.clipsToBounds = true
@@ -56,8 +54,9 @@ class AddGIFViewController: UIViewController {
return button
}()
private var currentTask: URLSessionDataTask?
var onSaveGIF: ((GIF) -> Void)?
private var currentTask: Any?
private var downloadedGIFData: Data?
var onSaveGIF: ((String, Data) -> Void)?
var onCancel: (() -> Void)?
override func viewDidLoad() {
@@ -91,7 +90,7 @@ class AddGIFViewController: UIViewController {
urlTextField.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 16),
urlTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
urlTextField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
previewGIFPlayer.topAnchor.constraint(equalTo: urlTextField.bottomAnchor, constant: 16),
previewGIFPlayer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
previewGIFPlayer.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
@@ -113,12 +112,12 @@ class AddGIFViewController: UIViewController {
}
private func setupActions() {
urlTextField.delegate = self
urlTextField.addTarget(self, action: #selector(urlTextDidChange), for: .editingChanged)
saveButton.addTarget(self, action: #selector(saveButtonTapped), for: .touchUpInside)
cancelButton.addTarget(self, action: #selector(cancelButtonTapped), for: .touchUpInside)
urlTextField.delegate = self
urlTextField.addTarget(self, action: #selector(urlTextDidChange), for: .editingChanged)
saveButton.addTarget(self, action: #selector(saveButtonTapped), for: .touchUpInside)
cancelButton.addTarget(self, action: #selector(cancelButtonTapped), for: .touchUpInside)
}
@objc private func urlTextDidChange() {
@@ -132,10 +131,13 @@ class AddGIFViewController: UIViewController {
}
private func loadGIFPreview(from urlString: String) {
// Cancel any existing task
currentTask?.cancel()
// Cancel any existing task if needed
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
return
}
@@ -143,33 +145,34 @@ class AddGIFViewController: UIViewController {
loadingIndicator.startAnimating()
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 {
guard let self = self else { return }
self.loadingIndicator.stopAnimating()
if let data = data, error == nil {
self.downloadedGIFData = data
self.previewGIFPlayer.loadGIF(from: data)
self.previewGIFPlayer.startAnimating()
self.saveButton.isEnabled = true
} else {
self.downloadedGIFData = nil
self.previewGIFPlayer.stopAnimating()
self.saveButton.isEnabled = false
}
}
}
currentTask?.resume()
}
@objc private func saveButtonTapped() {
guard let urlString = urlTextField.text, !urlString.isEmpty else { return }
let gif = GIF(urlString: urlString)
onSaveGIF?(gif)
dismiss(animated: true)
guard let urlString = urlTextField.text,
!urlString.isEmpty,
let gifData = downloadedGIFData
else { return }
onSaveGIF?(urlString, gifData)
dismiss(animated: true)
}
@objc private func cancelButtonTapped() {
@@ -179,8 +182,8 @@ class AddGIFViewController: UIViewController {
}
extension AddGIFViewController: UITextFieldDelegate {
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}
}

View File

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