2 Commits

Author SHA1 Message Date
Joshua Higgins
8ce5adc766 Save GIFs Locally 2025-06-03 22:18:23 -04:00
Joshua Higgins
a09e08763f Clean up 2025-06-02 20:17:55 -04:00
8 changed files with 335 additions and 200 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 data from local storage
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
if let gifData = GIFStorageService.shared.getGIFData(for: gif) {
// 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()
if let data = data, error == nil {
self.gifPlayerView.loadGIF(from: data)
self.gifData = gifData
self.gifPlayerView.loadGIF(from: gifData)
self.gifPlayerView.startAnimating()
self.placeholderLabel.isHidden = true
}
} else {
DispatchQueue.main.async {
guard let self = self else { return }
self.loadingIndicator.stopAnimating()
self.placeholderLabel.isHidden = false
}
}
}
currentTask?.resume()
}
}

View File

@@ -5,8 +5,8 @@
// Created by Joshua Higgins on 6/2/25.
//
import UIKit
import Messages
import UIKit
class MessagesViewController: MSMessagesAppViewController {
@@ -33,25 +33,21 @@ class MessagesViewController: MSMessagesAppViewController {
}
private func sendGIF(_ gif: GIF) {
guard let conversation = activeConversation,
let gifURL = gif.url else { return }
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)
// Download the GIF data
GIFDownloadService.shared.downloadGIF(from: gif.urlString) { data, error in
// 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) {
if let error = error {
self.showErrorAlert(error: error)
return
}
guard let gifData = data else {
self.showErrorAlert(message: "Failed to download GIF")
guard let gifData = gifData else {
self.showErrorAlert(message: "Failed to load GIF from storage")
return
}
@@ -82,7 +78,8 @@ class MessagesViewController: MSMessagesAppViewController {
private func showErrorAlert(error: Error? = nil, message: String? = nil) {
let errorMessage = message ?? error?.localizedDescription ?? "An unknown error occurred"
let alertController = UIAlertController(title: "Error", message: errorMessage, preferredStyle: .alert)
let alertController = UIAlertController(
title: "Error", message: errorMessage, preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: "OK", style: .default))
present(alertController, animated: true)
}
@@ -95,49 +92,12 @@ class MessagesViewController: MSMessagesAppViewController {
// Refresh GIFs list when becoming active
gifCollectionVC?.viewWillAppear(true)
// We don't need to check for custom message URLs anymore since
// we're sending standard GIF attachments
}
override func didResignActive(with conversation: MSConversation) {
// Called when the extension is about to move from the active to inactive state.
// This will happen when the user dismisses the extension, changes to a different
// conversation or quits Messages.
// Use this method to release shared resources, save user data, invalidate timers,
// and store enough state information to restore your extension to its current state
// in case it is terminated later.
}
override func didReceive(_ message: MSMessage, conversation: MSConversation) {
// Called when a message arrives that was generated by another instance of this
// extension on a remote device.
// Since we're now sending GIFs as standard attachments rather than
// custom messages, we don't need special handling for received messages
}
override func didStartSending(_ message: MSMessage, conversation: MSConversation) {
// Called when the user taps the send button.
}
override func didCancelSending(_ message: MSMessage, conversation: MSConversation) {
// Called when the user deletes the message without sending it.
// Use this to clean up state related to the deleted message.
}
override func willTransition(to presentationStyle: MSMessagesAppPresentationStyle) {
// Called before the extension transitions to a new presentation style.
// Use this method to prepare for the change in presentation style.
}
override func didTransition(to presentationStyle: MSMessagesAppPresentationStyle) {
// Called after the extension transitions to a new presentation style.
// Use this method to finalize any behaviors associated with the change in presentation style.
}
override func didResignActive(with conversation: MSConversation) {}
override func didReceive(_ message: MSMessage, conversation: MSConversation) {}
override func didStartSending(_ message: MSMessage, conversation: MSConversation) {}
override func didCancelSending(_ message: MSMessage, conversation: MSConversation) {}
override func willTransition(to presentationStyle: MSMessagesAppPresentationStyle) {}
override func didTransition(to presentationStyle: MSMessagesAppPresentationStyle) {}
}

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()
// 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() {
@@ -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,32 +145,33 @@ 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 }
guard let urlString = urlTextField.text,
!urlString.isEmpty,
let gifData = downloadedGIFData
else { return }
let gif = GIF(urlString: urlString)
onSaveGIF?(gif)
onSaveGIF?(urlString, gifData)
dismiss(animated: true)
}

View File

@@ -99,10 +99,13 @@ class GIFCollectionViewController: UIViewController {
@objc private func addButtonTapped() {
let addGIFVC = AddGIFViewController()
addGIFVC.onSaveGIF = { [weak self] gif in
GIFStorageService.shared.saveGIF(gif)
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
}