diff --git a/GIFCollector MessagesExtension/GIFCollectionViewCell.swift b/GIFCollector MessagesExtension/GIFCollectionViewCell.swift index f05b53c..1db93b9 100644 --- a/GIFCollector MessagesExtension/GIFCollectionViewCell.swift +++ b/GIFCollector MessagesExtension/GIFCollectionViewCell.swift @@ -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() } } \ No newline at end of file diff --git a/GIFCollector MessagesExtension/MessagesViewController.swift b/GIFCollector MessagesExtension/MessagesViewController.swift index 0d45021..74cbfde 100644 --- a/GIFCollector MessagesExtension/MessagesViewController.swift +++ b/GIFCollector MessagesExtension/MessagesViewController.swift @@ -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) { diff --git a/GIFCollector MessagesExtension/Models/GIF.swift b/GIFCollector MessagesExtension/Models/GIF.swift index 3a75d50..6173376 100644 --- a/GIFCollector MessagesExtension/Models/GIF.swift +++ b/GIFCollector MessagesExtension/Models/GIF.swift @@ -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() } diff --git a/GIFCollector MessagesExtension/Services/GIFDownloadService.swift b/GIFCollector MessagesExtension/Services/DownloadService.swift similarity index 78% rename from GIFCollector MessagesExtension/Services/GIFDownloadService.swift rename to GIFCollector MessagesExtension/Services/DownloadService.swift index e213fd4..591a795 100644 --- a/GIFCollector MessagesExtension/Services/GIFDownloadService.swift +++ b/GIFCollector MessagesExtension/Services/DownloadService.swift @@ -1,19 +1,19 @@ import Foundation import UIKit -class GIFDownloadService { - static let shared = GIFDownloadService() +class DownloadService { + static let shared = DownloadService() private let cache = NSCache() 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 } diff --git a/GIFCollector MessagesExtension/Services/GIFFileManager.swift b/GIFCollector MessagesExtension/Services/GIFFileManager.swift new file mode 100644 index 0000000..2d15cc5 --- /dev/null +++ b/GIFCollector MessagesExtension/Services/GIFFileManager.swift @@ -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.. 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) + } } \ No newline at end of file diff --git a/GIFCollector MessagesExtension/Views/AddGIFViewController.swift b/GIFCollector MessagesExtension/Views/AddGIFViewController.swift index d2b1fdf..965e349 100644 --- a/GIFCollector MessagesExtension/Views/AddGIFViewController.swift +++ b/GIFCollector MessagesExtension/Views/AddGIFViewController.swift @@ -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 + } } diff --git a/GIFCollector MessagesExtension/Views/GIFCollectionViewController.swift b/GIFCollector MessagesExtension/Views/GIFCollectionViewController.swift index 9c8f2dc..547ddf9 100644 --- a/GIFCollector MessagesExtension/Views/GIFCollectionViewController.swift +++ b/GIFCollector MessagesExtension/Views/GIFCollectionViewController.swift @@ -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 }