From 15525c9abee79e2b1d9f9e3e0f0b406e3c7837cb Mon Sep 17 00:00:00 2001 From: Joshua Higgins Date: Wed, 4 Jun 2025 23:02:24 -0400 Subject: [PATCH] Finished ShareSheet, Basic App, and restructure --- .../project.pbxproj | 28 +- .../contents.xcworkspacedata | 0 .../xcschemes/GIF Collector.xcscheme | 8 +- .../xcshareddata/xcschemes/gif.xcscheme | 6 +- .../xcshareddata/xcschemes/share.xcscheme | 8 +- GIFCollector/ContentView.swift | 340 ++++++++++++++--- GIFCollector/GIF.swift | 32 ++ .../GIFCollectionViewCell.swift | 0 GIFCollector/GIFCollector.entitlements | 2 +- GIFCollector/GIFCollectorApp.swift | 16 + GIFCollector/Item.swift | 18 - .../Services/DownloadService.swift | 0 .../Services/GIFFileManager.swift | 39 +- GIFCollector/Services/GIFStorageService.swift | 220 +++++++++++ .../Views/AddGIFViewController.swift | 0 .../Views/GIFCollectionViewController.swift | 0 .../Views/GIFPlayerView.swift | 0 GIFCollector/gifApp.swift | 32 -- GIFCollectorIM/GIFCollectorIM.entitlements | 2 +- GIFCollectorIM/Models/GIF.swift | 24 -- .../Services/GIFStorageService.swift | 135 ------- .../GIFCollectorShare.entitlements | 2 +- GIFCollectorShare/ShareViewController.swift | 354 +++++++++++++++++- 23 files changed, 966 insertions(+), 300 deletions(-) rename {gif.xcodeproj => GIF Collector.xcodeproj}/project.pbxproj (96%) rename {gif.xcodeproj => GIF Collector.xcodeproj}/project.xcworkspace/contents.xcworkspacedata (100%) rename {gif.xcodeproj => GIF Collector.xcodeproj}/xcshareddata/xcschemes/GIF Collector.xcscheme (92%) rename {gif.xcodeproj => GIF Collector.xcodeproj}/xcshareddata/xcschemes/gif.xcscheme (92%) rename {gif.xcodeproj => GIF Collector.xcodeproj}/xcshareddata/xcschemes/share.xcscheme (92%) create mode 100644 GIFCollector/GIF.swift rename {GIFCollectorIM => GIFCollector}/GIFCollectionViewCell.swift (100%) create mode 100644 GIFCollector/GIFCollectorApp.swift delete mode 100644 GIFCollector/Item.swift rename {GIFCollectorIM => GIFCollector}/Services/DownloadService.swift (100%) rename {GIFCollectorIM => GIFCollector}/Services/GIFFileManager.swift (80%) create mode 100644 GIFCollector/Services/GIFStorageService.swift rename {GIFCollectorIM => GIFCollector}/Views/AddGIFViewController.swift (100%) rename {GIFCollectorIM => GIFCollector}/Views/GIFCollectionViewController.swift (100%) rename {GIFCollectorIM => GIFCollector}/Views/GIFPlayerView.swift (100%) delete mode 100644 GIFCollector/gifApp.swift delete mode 100644 GIFCollectorIM/Models/GIF.swift delete mode 100644 GIFCollectorIM/Services/GIFStorageService.swift diff --git a/gif.xcodeproj/project.pbxproj b/GIF Collector.xcodeproj/project.pbxproj similarity index 96% rename from gif.xcodeproj/project.pbxproj rename to GIF Collector.xcodeproj/project.pbxproj index 783c2a1..0c33d95 100644 --- a/gif.xcodeproj/project.pbxproj +++ b/GIF Collector.xcodeproj/project.pbxproj @@ -66,11 +66,29 @@ ); target = 1B29522F2DF0B734002148EF /* GIFCollectorShare */; }; + 1B4125C72DF0D5FB001B8215 /* Exceptions for "GIFCollector" folder in "GIFCollectorIM" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + ContentView.swift, + GIF.swift, + GIFCollectionViewCell.swift, + Services/DownloadService.swift, + Services/GIFFileManager.swift, + Services/GIFStorageService.swift, + Views/AddGIFViewController.swift, + Views/GIFCollectionViewController.swift, + Views/GIFPlayerView.swift, + ); + target = 1B2952162DF0B6A1002148EF /* GIFCollectorIM */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ 1B2952052DF0B611002148EF /* GIFCollector */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 1B4125C72DF0D5FB001B8215 /* Exceptions for "GIFCollector" folder in "GIFCollectorIM" target */, + ); path = GIFCollector; sourceTree = ""; }; @@ -240,7 +258,7 @@ }; }; }; - buildConfigurationList = 1B2951FE2DF0B611002148EF /* Build configuration list for PBXProject "gif" */; + buildConfigurationList = 1B2951FE2DF0B611002148EF /* Build configuration list for PBXProject "GIF Collector" */; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -448,6 +466,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = GIFCollector/GIFCollector.entitlements; + CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; @@ -480,6 +499,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = GIFCollector/GIFCollector.entitlements; + CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; @@ -511,6 +531,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "iMessage App Icon"; CODE_SIGN_ENTITLEMENTS = GIFCollectorIM/GIFCollectorIM.entitlements; + CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; @@ -539,6 +560,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "iMessage App Icon"; CODE_SIGN_ENTITLEMENTS = GIFCollectorIM/GIFCollectorIM.entitlements; + CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; @@ -566,6 +588,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = GIFCollectorShare/GIFCollectorShare.entitlements; + CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; @@ -593,6 +616,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = GIFCollectorShare/GIFCollectorShare.entitlements; + CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; @@ -619,7 +643,7 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 1B2951FE2DF0B611002148EF /* Build configuration list for PBXProject "gif" */ = { + 1B2951FE2DF0B611002148EF /* Build configuration list for PBXProject "GIF Collector" */ = { isa = XCConfigurationList; buildConfigurations = ( 1B29520E2DF0B613002148EF /* Debug */, diff --git a/gif.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/GIF Collector.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 100% rename from gif.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename to GIF Collector.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/gif.xcodeproj/xcshareddata/xcschemes/GIF Collector.xcscheme b/GIF Collector.xcodeproj/xcshareddata/xcschemes/GIF Collector.xcscheme similarity index 92% rename from gif.xcodeproj/xcshareddata/xcschemes/GIF Collector.xcscheme rename to GIF Collector.xcodeproj/xcshareddata/xcschemes/GIF Collector.xcscheme index a7019f7..8eb9a23 100644 --- a/gif.xcodeproj/xcshareddata/xcschemes/GIF Collector.xcscheme +++ b/GIF Collector.xcodeproj/xcshareddata/xcschemes/GIF Collector.xcscheme @@ -19,7 +19,7 @@ BlueprintIdentifier = "1B2952162DF0B6A1002148EF" BuildableName = "GIFCollectorIM.appex" BlueprintName = "GIFCollectorIM" - ReferencedContainer = "container:gif.xcodeproj"> + ReferencedContainer = "container:GIF Collector.xcodeproj"> + ReferencedContainer = "container:GIF Collector.xcodeproj"> @@ -64,7 +64,7 @@ BlueprintIdentifier = "1B2952022DF0B611002148EF" BuildableName = "GIFCollector.app" BlueprintName = "GIFCollector" - ReferencedContainer = "container:gif.xcodeproj"> + ReferencedContainer = "container:GIF Collector.xcodeproj"> @@ -83,7 +83,7 @@ BlueprintIdentifier = "1B2952022DF0B611002148EF" BuildableName = "GIFCollector.app" BlueprintName = "GIFCollector" - ReferencedContainer = "container:gif.xcodeproj"> + ReferencedContainer = "container:GIF Collector.xcodeproj"> diff --git a/gif.xcodeproj/xcshareddata/xcschemes/gif.xcscheme b/GIF Collector.xcodeproj/xcshareddata/xcschemes/gif.xcscheme similarity index 92% rename from gif.xcodeproj/xcshareddata/xcschemes/gif.xcscheme rename to GIF Collector.xcodeproj/xcshareddata/xcschemes/gif.xcscheme index 17d5796..966d0fc 100644 --- a/gif.xcodeproj/xcshareddata/xcschemes/gif.xcscheme +++ b/GIF Collector.xcodeproj/xcshareddata/xcschemes/gif.xcscheme @@ -18,7 +18,7 @@ BlueprintIdentifier = "1B2952022DF0B611002148EF" BuildableName = "GIFCollector.app" BlueprintName = "GIFCollector" - ReferencedContainer = "container:gif.xcodeproj"> + ReferencedContainer = "container:GIF Collector.xcodeproj"> @@ -47,7 +47,7 @@ BlueprintIdentifier = "1B2952022DF0B611002148EF" BuildableName = "GIFCollector.app" BlueprintName = "GIFCollector" - ReferencedContainer = "container:gif.xcodeproj"> + ReferencedContainer = "container:GIF Collector.xcodeproj"> @@ -64,7 +64,7 @@ BlueprintIdentifier = "1B2952022DF0B611002148EF" BuildableName = "GIFCollector.app" BlueprintName = "GIFCollector" - ReferencedContainer = "container:gif.xcodeproj"> + ReferencedContainer = "container:GIF Collector.xcodeproj"> diff --git a/gif.xcodeproj/xcshareddata/xcschemes/share.xcscheme b/GIF Collector.xcodeproj/xcshareddata/xcschemes/share.xcscheme similarity index 92% rename from gif.xcodeproj/xcshareddata/xcschemes/share.xcscheme rename to GIF Collector.xcodeproj/xcshareddata/xcschemes/share.xcscheme index cd8ef21..0bfe2c5 100644 --- a/gif.xcodeproj/xcshareddata/xcschemes/share.xcscheme +++ b/GIF Collector.xcodeproj/xcshareddata/xcschemes/share.xcscheme @@ -19,7 +19,7 @@ BlueprintIdentifier = "1B29522F2DF0B734002148EF" BuildableName = "GIFCollectorShare.appex" BlueprintName = "GIFCollectorShare" - ReferencedContainer = "container:gif.xcodeproj"> + ReferencedContainer = "container:GIF Collector.xcodeproj"> + ReferencedContainer = "container:GIF Collector.xcodeproj"> @@ -64,7 +64,7 @@ BlueprintIdentifier = "1B2952022DF0B611002148EF" BuildableName = "GIFCollector.app" BlueprintName = "GIFCollector" - ReferencedContainer = "container:gif.xcodeproj"> + ReferencedContainer = "container:GIF Collector.xcodeproj"> @@ -83,7 +83,7 @@ BlueprintIdentifier = "1B2952022DF0B611002148EF" BuildableName = "GIFCollector.app" BlueprintName = "GIFCollector" - ReferencedContainer = "container:gif.xcodeproj"> + ReferencedContainer = "container:GIF Collector.xcodeproj"> diff --git a/GIFCollector/ContentView.swift b/GIFCollector/ContentView.swift index b307323..cca5c90 100644 --- a/GIFCollector/ContentView.swift +++ b/GIFCollector/ContentView.swift @@ -1,61 +1,315 @@ -// -// ContentView.swift -// gif -// -// Created by Joshua Higgins on 6/4/25. -// - import SwiftUI -import SwiftData +import UIKit +import UniformTypeIdentifiers struct ContentView: View { - @Environment(\.modelContext) private var modelContext - @Query private var items: [Item] + @State private var gifs: [GIF] = [] + @State private var showingAddGIF = false + @State private var showingAlert = false + @State private var alertTitle = "" + @State private var alertMessage = "" - var body: some View { - NavigationSplitView { - List { - ForEach(items) { item in - NavigationLink { - Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))") - } label: { - Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard)) - } - } - .onDelete(perform: deleteItems) + // For handling app lifecycle notifications + @Environment(\.scenePhase) private var scenePhase + + var body: some View { + NavigationView { + ZStack { + if gifs.isEmpty { + VStack { + Text("No GIFs saved") + .font(.headline) + Text("Add your first GIF using the + button") + .font(.subheadline) + .foregroundColor(.secondary) + } + } else { + ScrollView { + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 10), + GridItem(.flexible(), spacing: 10), + ], spacing: 10 + ) { + ForEach(gifs) { gif in + GIFCell( + gif: gif, + onTap: { + copyGIFToClipboard(gif) + }, + onDelete: { + deleteGIF(gif) + }) + } } - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - EditButton() - } - ToolbarItem { - Button(action: addItem) { - Label("Add Item", systemImage: "plus") - } - } - } - } detail: { - Text("Select an item") + .padding() + } } + } + .navigationTitle("GIF Collector") + .toolbar { + Button(action: { + showingAddGIF = true + }) { + Image(systemName: "plus.circle.fill") + .resizable() + .frame(width: 24, height: 24) + } + } + .sheet(isPresented: $showingAddGIF) { + AddGIFView { urlString, gifData in + saveGIF(urlString: urlString, data: gifData) + showingAddGIF = false + } onCancel: { + showingAddGIF = false + } + } + .alert(isPresented: $showingAlert) { + Alert( + title: Text(alertTitle), message: Text(alertMessage), dismissButton: .default(Text("OK"))) + } + .onAppear { + loadGIFs() + + // Register for foreground notification + NotificationCenter.default.addObserver( + forName: UIApplication.willEnterForegroundNotification, + object: nil, + queue: .main + ) { _ in + loadGIFs() + } + } + .onDisappear { + // Remove notification observer when view disappears + NotificationCenter.default.removeObserver( + self, + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + } + // Also monitor scene phase changes + .onChange(of: scenePhase) { newPhase in + if newPhase == .active { + loadGIFs() + } + } + } + } + + private func loadGIFs() { + // Check for shared GIFs on app launch + GIFStorageService.shared.checkForSharedGIFs() + gifs = GIFStorageService.shared.fetchGIFs() + } + + private func saveGIF(urlString: String, data: Data) { + GIFStorageService.shared.saveGIF(data: data, fromURL: urlString) { _ in + loadGIFs() + } + } + + private func deleteGIF(_ gif: GIF) { + print("Got delete request for \(gif.id)") + GIFStorageService.shared.deleteGIF(with: gif.id) + loadGIFs() + } + + private func copyGIFToClipboard(_ gif: GIF) { + guard let data = GIFStorageService.shared.getGIFData(for: gif) else { + showAlert(title: "Error", message: "Could not load GIF data") + return } - private func addItem() { - withAnimation { - let newItem = Item(timestamp: Date()) - modelContext.insert(newItem) + let pasteboard = UIPasteboard.general + pasteboard.setData(data, forPasteboardType: UTType.gif.identifier) + + showAlert(title: "Copied!", message: "GIF copied to clipboard") + } + + private func showAlert(title: String, message: String) { + alertTitle = title + alertMessage = message + showingAlert = true + } +} + +struct GIFCell: View { + let gif: GIF + let onTap: () -> Void + let onDelete: () -> Void + + @State private var showingDeleteConfirmation = false + + var body: some View { + ZStack { + GIFPlayerUIView(filePath: gif.localFilePath) + .aspectRatio(1, contentMode: .fill) + .cornerRadius(8) + + // Tap hint overlay + Color.black.opacity(0.0001) // Almost transparent for hit testing + } + .aspectRatio(1, contentMode: .fit) + .cornerRadius(8) + .clipped() + .shadow(radius: 2) + .onTapGesture { + onTap() + } + .contextMenu { + Button( + role: .destructive, + action: { + onDelete() } + ) { + Label("Delete", systemImage: "trash") + } + + Button(action: { + onTap() + }) { + Label("Copy to Clipboard", systemImage: "doc.on.clipboard") + } + } + } +} + +struct GIFPlayerUIView: UIViewRepresentable { + let filePath: String + + func makeUIView(context: Context) -> UIView { + let view = UIView() + let gifPlayerView = GIFPlayerView(frame: view.bounds) + gifPlayerView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(gifPlayerView) + + if let data = try? Data(contentsOf: URL(fileURLWithPath: filePath)) { + gifPlayerView.loadGIF(from: data) + gifPlayerView.startAnimating() } - private func deleteItems(offsets: IndexSet) { - withAnimation { - for index in offsets { - modelContext.delete(items[index]) + return view + } + + func updateUIView(_ uiView: UIView, context: Context) { + // Nothing to update + } +} + +struct AddGIFView: View { + @State private var urlString = "" + @State private var isLoading = false + @State private var previewData: Data? + @State private var showError = false + @State private var errorMessage = "" + + let onSave: (String, Data) -> Void + let onCancel: () -> Void + + var body: some View { + NavigationView { + VStack(spacing: 20) { + TextField("Enter GIF URL", text: $urlString) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .autocapitalization(.none) + .disableAutocorrection(true) + .onChange(of: urlString) { _ in + if !urlString.isEmpty { + loadPreview() + } else { + previewData = nil } + } + + if isLoading { + ProgressView() + .frame(height: 200) + } else if let data = previewData { + GIFPreview(data: data) + .frame(height: 200) + .cornerRadius(8) + } else { + Rectangle() + .fill(Color.gray.opacity(0.2)) + .frame(height: 200) + .cornerRadius(8) + .overlay( + Text("GIF Preview") + .foregroundColor(.gray) + ) } + + Spacer() + } + .padding() + .navigationTitle("Add New GIF") + .navigationBarItems( + leading: Button("Cancel") { + onCancel() + }, + trailing: Button("Save") { + if let data = previewData { + onSave(urlString, data) + } + } + .disabled(previewData == nil) + ) + .alert(isPresented: $showError) { + Alert( + title: Text("Error"), message: Text(errorMessage), dismissButton: .default(Text("OK"))) + } } + } + + private func loadPreview() { + guard URL(string: urlString) != nil else { + return + } + + isLoading = true + previewData = nil + + DownloadService.shared.downloadGIF(from: urlString) { data, error in + isLoading = false + + if let error = error { + errorMessage = error.localizedDescription + showError = true + return + } + + if let data = data { + previewData = data + } else { + errorMessage = "Failed to load GIF" + showError = true + } + } + } +} + +struct GIFPreview: UIViewRepresentable { + let data: Data + + func makeUIView(context: Context) -> UIView { + let view = UIView() + let gifPlayerView = GIFPlayerView(frame: view.bounds) + gifPlayerView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(gifPlayerView) + + gifPlayerView.loadGIF(from: data) + gifPlayerView.startAnimating() + + return view + } + + func updateUIView(_ uiView: UIView, context: Context) { + // Nothing to update + } } #Preview { - ContentView() - .modelContainer(for: Item.self, inMemory: true) + ContentView() } diff --git a/GIFCollector/GIF.swift b/GIFCollector/GIF.swift new file mode 100644 index 0000000..451bda4 --- /dev/null +++ b/GIFCollector/GIF.swift @@ -0,0 +1,32 @@ +import Foundation + +// Removed SwiftData dependency to ensure compatibility across targets +struct GIF: Codable, Identifiable, Equatable { + let id: UUID + let localFilePath: String + let originalURL: String + let createdAt: Date + + var fileURL: URL? { + return URL(fileURLWithPath: localFilePath) + } + + init(localFilePath: String, originalURL: String) { + self.id = UUID() + self.localFilePath = localFilePath + self.originalURL = originalURL + self.createdAt = Date() + } + + init(localFilePath: String, originalURL: String, id: UUID, createdAt: Date) { + self.id = id + self.localFilePath = localFilePath + self.originalURL = originalURL + self.createdAt = createdAt + } + + // For equality comparison + static func == (lhs: GIF, rhs: GIF) -> Bool { + return lhs.id == rhs.id + } +} diff --git a/GIFCollectorIM/GIFCollectionViewCell.swift b/GIFCollector/GIFCollectionViewCell.swift similarity index 100% rename from GIFCollectorIM/GIFCollectionViewCell.swift rename to GIFCollector/GIFCollectionViewCell.swift diff --git a/GIFCollector/GIFCollector.entitlements b/GIFCollector/GIFCollector.entitlements index b80d31e..55d92e4 100644 --- a/GIFCollector/GIFCollector.entitlements +++ b/GIFCollector/GIFCollector.entitlements @@ -4,7 +4,7 @@ com.apple.security.application-groups - group.com.abunchofknowitalls.GIFCollector + group.gifcollector diff --git a/GIFCollector/GIFCollectorApp.swift b/GIFCollector/GIFCollectorApp.swift new file mode 100644 index 0000000..03348a4 --- /dev/null +++ b/GIFCollector/GIFCollectorApp.swift @@ -0,0 +1,16 @@ +import SwiftUI + +@main +struct GIFCollectorApp: App { + init() { + // Initialize any services here + let _ = GIFStorageService.shared + let _ = GIFFileManager.shared + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} \ No newline at end of file diff --git a/GIFCollector/Item.swift b/GIFCollector/Item.swift deleted file mode 100644 index 0d9a6d7..0000000 --- a/GIFCollector/Item.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// Item.swift -// gif -// -// Created by Joshua Higgins on 6/4/25. -// - -import Foundation -import SwiftData - -@Model -final class Item { - var timestamp: Date - - init(timestamp: Date) { - self.timestamp = timestamp - } -} diff --git a/GIFCollectorIM/Services/DownloadService.swift b/GIFCollector/Services/DownloadService.swift similarity index 100% rename from GIFCollectorIM/Services/DownloadService.swift rename to GIFCollector/Services/DownloadService.swift diff --git a/GIFCollectorIM/Services/GIFFileManager.swift b/GIFCollector/Services/GIFFileManager.swift similarity index 80% rename from GIFCollectorIM/Services/GIFFileManager.swift rename to GIFCollector/Services/GIFFileManager.swift index f1dc734..2165156 100644 --- a/GIFCollectorIM/Services/GIFFileManager.swift +++ b/GIFCollector/Services/GIFFileManager.swift @@ -10,19 +10,26 @@ class GIFFileManager { // MARK: - File Storage + // Always use the App Group container for consistent storage between extensions private var documentsDirectory: URL { - // First try to get the App Group container - if let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.gifcollector") { - return containerURL - } + // Use a constant app group ID to ensure consistency across targets + let appGroupID = "group.gifcollector" - // Fall back to the app's documents directory if App Group is not available - let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) - return paths[0] + if let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID) { + return containerURL + } else { + // This should only happen if App Group capability is not properly set up + print("ERROR: App Group container not available. GIFs will not be shared between extensions!") + + // Fall back to the app's documents directory if App Group is not available + let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) + return paths[0] + } } private var gifsDirectory: URL { - return documentsDirectory.appendingPathComponent("SavedGIFs", isDirectory: true) + // Use a consistent folder name for GIFs across all extensions + return documentsDirectory.appendingPathComponent("SharedGIFs", isDirectory: true) } private func createGIFsDirectoryIfNeeded() { @@ -31,9 +38,12 @@ class GIFFileManager { if !fileManager.fileExists(atPath: gifsDirectory.path) { do { try fileManager.createDirectory(at: gifsDirectory, withIntermediateDirectories: true) + print("Created shared GIFs directory at: \(gifsDirectory.path)") } catch { - print("Error creating GIFs directory: \(error)") + print("Error creating shared GIFs directory: \(error)") } + } else { + print("Using existing shared GIFs directory at: \(gifsDirectory.path)") } } @@ -143,18 +153,13 @@ class GIFFileManager { // Check if we already have this GIF in the shared container private func checkForSharedGIF(withURL urlString: String) -> String? { - guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.gifcollector") else { - return nil - } - - let sharedGIFsFolder = containerURL.appendingPathComponent("GIFs", isDirectory: true) - - guard FileManager.default.fileExists(atPath: sharedGIFsFolder.path) else { + // Use our standard gifsDirectory property instead of recreating the path + guard FileManager.default.fileExists(atPath: gifsDirectory.path) else { return nil } do { - let fileURLs = try FileManager.default.contentsOfDirectory(at: sharedGIFsFolder, includingPropertiesForKeys: nil) + let fileURLs = try FileManager.default.contentsOfDirectory(at: gifsDirectory, includingPropertiesForKeys: nil) // Find any shared GIF that matches our URL (usually won't find any, but helps avoid duplicates) let userDefaults = UserDefaults(suiteName: "group.gifcollector") diff --git a/GIFCollector/Services/GIFStorageService.swift b/GIFCollector/Services/GIFStorageService.swift new file mode 100644 index 0000000..5bac2f6 --- /dev/null +++ b/GIFCollector/Services/GIFStorageService.swift @@ -0,0 +1,220 @@ +import Foundation + +class GIFStorageService { + static let shared = GIFStorageService() + + // Use constant App Group ID to ensure consistency across targets + private let appGroupID = "group.gifcollector" + private let userDefaults = UserDefaults(suiteName: "group.gifcollector") + + // Use a consistent key for saved GIFs across all extensions + private let savedGIFsKey = "sharedGIFs" + private let pendingGIFsKey = "pendingGIFs" + + private init() { + // Make sure the shared UserDefaults exists + if userDefaults == nil { + print("ERROR: Could not create UserDefaults with app group. GIFs will not be shared between extensions!") + } else { + print("Successfully connected to shared UserDefaults") + } + + // Process any pending GIFs from the Share Extension + checkForSharedGIFs() + + // Log the number of GIFs found + let count = fetchGIFs().count + print("Found \(count) GIFs in shared storage") + } + + 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 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] { + guard let userDefaultsInstance = userDefaults else { + print("ERROR: UserDefaults is nil, cannot fetch GIFs") + return [] + } + + guard let data = userDefaultsInstance.data(forKey: savedGIFsKey) else { + print("No GIFs data found in UserDefaults for key: \(savedGIFsKey)") + return [] + } + + do { + let gifs = try JSONDecoder().decode([GIF].self, from: data) + print("Fetched \(gifs.count) GIFs from shared storage") + + // Debug IDs + if !gifs.isEmpty { + print("GIF IDs: \(gifs.map { $0.id.uuidString.prefix(8) }.joined(separator: ", "))") + } + + // Filter out any GIFs whose files no longer exist + let validGIFs = gifs.filter { + let exists = GIFFileManager.shared.fileExists(at: $0.localFilePath) + if !exists { + print("Warning: GIF file not found at path: \($0.localFilePath)") + } + return exists + } + + // If we filtered any out, save the updated list + if validGIFs.count != gifs.count { + print("Removed \(gifs.count - validGIFs.count) invalid GIFs from storage") + saveToUserDefaults(gifs: validGIFs) + } + + return validGIFs.sorted(by: { $0.createdAt > $1.createdAt }) + } catch { + print("ERROR: Failed to decode GIFs from UserDefaults: \(error)") + return [] + } + } + + func deleteGIF(with id: UUID) { + var savedGIFs = fetchGIFs() + + // Find the GIF to delete + if let gifToDelete = savedGIFs.first(where: { $0.id == id }) { + print("Deleting GIF with ID: \(id), path: \(gifToDelete.localFilePath)") + + // Delete the file from storage + let fileDeleted = GIFFileManager.shared.deleteGIF(at: gifToDelete.localFilePath) + if fileDeleted { + print("Successfully deleted GIF file from storage") + } else { + print("Warning: Failed to delete GIF file at path: \(gifToDelete.localFilePath)") + } + + // Remove from the list regardless of whether file deletion succeeded + let countBefore = savedGIFs.count + savedGIFs.removeAll(where: { $0.id == id }) + + if savedGIFs.count < countBefore { + print("Removed GIF from saved list, count before: \(countBefore), after: \(savedGIFs.count)") + saveToUserDefaults(gifs: savedGIFs) + } else { + print("Error: GIF with ID \(id) was not found in memory array") + } + } else { + print("Error: Could not find GIF with ID \(id) to delete") + } + } + + func clearAllGIFs() { + // Delete all GIF files + fetchGIFs().forEach { gif in + GIFFileManager.shared.deleteGIF(at: gif.localFilePath) + } + + // Clear the list + saveToUserDefaults(gifs: []) + } + + private func saveToUserDefaults(gifs: [GIF]) { + do { + let data = try JSONEncoder().encode(gifs) + userDefaults?.set(data, forKey: savedGIFsKey) + print("Successfully saved \(gifs.count) GIFs to shared storage") + + // Force UserDefaults to synchronize to ensure immediate visibility across extensions + if userDefaults?.synchronize() == true { + print("UserDefaults successfully synchronized") + } else { + print("Warning: UserDefaults synchronize may have failed") + } + + // Double-check that our changes were actually saved + if let checkData = userDefaults?.data(forKey: savedGIFsKey), + let checkGifs = try? JSONDecoder().decode([GIF].self, from: checkData) { + print("Verified: \(checkGifs.count) GIFs stored in UserDefaults") + } else { + print("ERROR: Failed to verify GIFs were saved to UserDefaults") + } + } catch { + print("ERROR: Failed to encode GIFs for storage: \(error)") + } + } + + func getGIFData(for gif: GIF) -> Data? { + return GIFFileManager.shared.loadGIFData(from: gif.localFilePath) + } + + // MARK: - Share Extension Integration + + func checkForSharedGIFs() { + guard let pendingGIFsData = userDefaults?.array(forKey: pendingGIFsKey) as? [[String: Any]] else { + print("No pending GIFs found in shared storage") + return + } + + guard !pendingGIFsData.isEmpty else { + print("Pending GIFs array is empty") + return + } + + print("Found \(pendingGIFsData.count) pending GIFs to process") + + var savedGIFs = fetchGIFs() + var newGIFsAdded = false + + for gifInfo in pendingGIFsData { + if let localFilePath = gifInfo["localFilePath"] as? String, + let originalURL = gifInfo["originalURL"] as? String, + let createdAt = gifInfo["createdAt"] as? TimeInterval { + + // Create a GIF object - use the provided ID if available, or create a new one + let gifId = (gifInfo["id"] as? String).flatMap { UUID(uuidString: $0) } ?? UUID() + let gifCreatedAt = Date(timeIntervalSince1970: createdAt) + + let gif = GIF( + localFilePath: localFilePath, + originalURL: originalURL, + id: gifId, + createdAt: gifCreatedAt + ) + + // Don't add duplicates + if !savedGIFs.contains(where: { $0.localFilePath == localFilePath }) { + savedGIFs.append(gif) + newGIFsAdded = true + } + } + } + + // Save updated GIFs list and clear pending ones + if newGIFsAdded { + print("Added \(pendingGIFsData.count) new GIFs from shared extension") + saveToUserDefaults(gifs: savedGIFs) + } else { + print("No new GIFs were added from pending list") + } + + // Clear pending GIFs + userDefaults?.removeObject(forKey: pendingGIFsKey) + userDefaults?.synchronize() + print("Cleared pending GIFs from shared storage") + } +} \ No newline at end of file diff --git a/GIFCollectorIM/Views/AddGIFViewController.swift b/GIFCollector/Views/AddGIFViewController.swift similarity index 100% rename from GIFCollectorIM/Views/AddGIFViewController.swift rename to GIFCollector/Views/AddGIFViewController.swift diff --git a/GIFCollectorIM/Views/GIFCollectionViewController.swift b/GIFCollector/Views/GIFCollectionViewController.swift similarity index 100% rename from GIFCollectorIM/Views/GIFCollectionViewController.swift rename to GIFCollector/Views/GIFCollectionViewController.swift diff --git a/GIFCollectorIM/Views/GIFPlayerView.swift b/GIFCollector/Views/GIFPlayerView.swift similarity index 100% rename from GIFCollectorIM/Views/GIFPlayerView.swift rename to GIFCollector/Views/GIFPlayerView.swift diff --git a/GIFCollector/gifApp.swift b/GIFCollector/gifApp.swift deleted file mode 100644 index 889389b..0000000 --- a/GIFCollector/gifApp.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// gifApp.swift -// gif -// -// Created by Joshua Higgins on 6/4/25. -// - -import SwiftUI -import SwiftData - -@main -struct gifApp: App { - var sharedModelContainer: ModelContainer = { - let schema = Schema([ - Item.self, - ]) - let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) - - do { - return try ModelContainer(for: schema, configurations: [modelConfiguration]) - } catch { - fatalError("Could not create ModelContainer: \(error)") - } - }() - - var body: some Scene { - WindowGroup { - ContentView() - } - .modelContainer(sharedModelContainer) - } -} diff --git a/GIFCollectorIM/GIFCollectorIM.entitlements b/GIFCollectorIM/GIFCollectorIM.entitlements index b80d31e..55d92e4 100644 --- a/GIFCollectorIM/GIFCollectorIM.entitlements +++ b/GIFCollectorIM/GIFCollectorIM.entitlements @@ -4,7 +4,7 @@ com.apple.security.application-groups - group.com.abunchofknowitalls.GIFCollector + group.gifcollector diff --git a/GIFCollectorIM/Models/GIF.swift b/GIFCollectorIM/Models/GIF.swift deleted file mode 100644 index 6173376..0000000 --- a/GIFCollectorIM/Models/GIF.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Foundation -import UIKit - -struct GIF: Codable, Identifiable, Equatable { - let id: UUID - let localFilePath: String - let createdAt: Date - let originalURL: String - - var fileURL: URL? { - return URL(fileURLWithPath: localFilePath) - } - - init(localFilePath: String, originalURL: String) { - self.id = UUID() - self.localFilePath = localFilePath - self.originalURL = originalURL - self.createdAt = Date() - } - - static func == (lhs: GIF, rhs: GIF) -> Bool { - return lhs.id == rhs.id - } -} \ No newline at end of file diff --git a/GIFCollectorIM/Services/GIFStorageService.swift b/GIFCollectorIM/Services/GIFStorageService.swift deleted file mode 100644 index fc1c835..0000000 --- a/GIFCollectorIM/Services/GIFStorageService.swift +++ /dev/null @@ -1,135 +0,0 @@ -import Foundation - -class GIFStorageService { - static let shared = GIFStorageService() - - private let userDefaults = UserDefaults(suiteName: "group.gifcollector") - private let savedGIFsKey = "savedGIFs" - private let pendingGIFsKey = "pendingGIFs" - - private init() { - // Make sure the shared UserDefaults exists - if userDefaults == nil { - print("Error: Could not create UserDefaults with app group") - } - - // Process any pending GIFs from the Share Extension - checkForSharedGIFs() - } - - 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 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] { - guard let data = userDefaults?.data(forKey: savedGIFsKey), - let gifs = try? JSONDecoder().decode([GIF].self, from: data) else { - return [] - } - - // 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: []) - } - - private func saveToUserDefaults(gifs: [GIF]) { - 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) - } - - // MARK: - Share Extension Integration - - func checkForSharedGIFs() { - guard let pendingGIFsData = userDefaults?.array(forKey: pendingGIFsKey) as? [[String: Any]] else { - return - } - - guard !pendingGIFsData.isEmpty else { - return - } - - var savedGIFs = fetchGIFs() - var newGIFsAdded = false - - for gifInfo in pendingGIFsData { - if let localFilePath = gifInfo["localFilePath"] as? String, - let originalURL = gifInfo["originalURL"] as? String, - let createdAt = gifInfo["createdAt"] as? TimeInterval { - - // Create a GIF object - let gif = GIF( - localFilePath: localFilePath, - originalURL: originalURL - ) - - // Don't add duplicates - if !savedGIFs.contains(where: { $0.localFilePath == localFilePath }) { - savedGIFs.append(gif) - newGIFsAdded = true - } - } - } - - // Save updated GIFs list and clear pending ones - if newGIFsAdded { - saveToUserDefaults(gifs: savedGIFs) - } - - // Clear pending GIFs - userDefaults?.removeObject(forKey: pendingGIFsKey) - } -} \ No newline at end of file diff --git a/GIFCollectorShare/GIFCollectorShare.entitlements b/GIFCollectorShare/GIFCollectorShare.entitlements index b80d31e..55d92e4 100644 --- a/GIFCollectorShare/GIFCollectorShare.entitlements +++ b/GIFCollectorShare/GIFCollectorShare.entitlements @@ -4,7 +4,7 @@ com.apple.security.application-groups - group.com.abunchofknowitalls.GIFCollector + group.gifcollector diff --git a/GIFCollectorShare/ShareViewController.swift b/GIFCollectorShare/ShareViewController.swift index edb7182..053aa88 100644 --- a/GIFCollectorShare/ShareViewController.swift +++ b/GIFCollectorShare/ShareViewController.swift @@ -1,30 +1,354 @@ // // ShareViewController.swift -// GIFCollector ShareExtension -// -// Created by Joshua Higgins on 6/3/25. +// GIFCollectorShare // -import UIKit +import MobileCoreServices import Social +import UIKit +import UniformTypeIdentifiers class ShareViewController: SLComposeServiceViewController { - override func isContentValid() -> Bool { - // Do validation of contentText and/or NSExtensionContext attachments here - return true + private var receivedURL: URL? + private var gifData: Data? + private var isProcessing = false + private var originalURL: String = "" + private var debugMessages: [String] = [] + + // Use the same constants as in the main app + private let appGroupID = "group.gifcollector" + private let pendingGIFsKey = "pendingGIFs" + + override func viewDidLoad() { + super.viewDidLoad() + title = "Save to GIF Collector" + placeholder = "Add a note (optional)" + + // Add debug button in development + #if DEBUG + let debugButton = UIBarButtonItem( + title: "Debug", style: .plain, target: self, action: #selector(showDebugInfo)) + navigationItem.leftBarButtonItem = debugButton + #endif + + // Start processing the shared item + processSharedItem() + + addDebugMessage("ShareViewController loaded") + } + + override func isContentValid() -> Bool { + // No validation needed for the text field, we just want the GIF + return true + } + + override func didSelectPost() { + // Show loading + isProcessing = true + navigationController?.navigationBar.isUserInteractionEnabled = false + + // If we have a URL but no data, we need to download it + if let receivedURL = receivedURL, gifData == nil { + downloadGIF(from: receivedURL) { [weak self] data, error in + guard let self = self else { return } + + if let data = data { + self.gifData = data + self.saveGIF() + } else { + self.showError( + message: "Could not download GIF: \(error?.localizedDescription ?? "Unknown error")") + } + } + } else if let gifData = gifData { + // We already have the data, save it directly + saveGIF() + } else { + // No valid GIF was found + showError(message: "No valid GIF found in the shared content") + } + } + + override func didSelectCancel() { + // User canceled, close the extension + completeRequest() + } + + // MARK: - Private Methods + + private func processSharedItem() { + addDebugMessage("Starting to process shared item") + + // Log extension context info + if let context = extensionContext { + addDebugMessage("Extension context exists") + addDebugMessage("Input items count: \(context.inputItems.count)") + } else { + addDebugMessage("Extension context is nil") } - override func didSelectPost() { - // This is called after the user selects Post. Do the upload of contentText and/or NSExtensionContext attachments. - - // Inform the host that we're done, so it un-blocks its UI. Note: Alternatively you could call super's -didSelectPost, which will similarly complete the extension context. - self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil) + guard let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem else { + addDebugMessage("No extension item found") + showError(message: "No shared content found") + return } - override func configurationItems() -> [Any]! { - // To add configuration options via table cells at the bottom of the sheet, return an array of SLComposeSheetConfigurationItem here. - return [] + guard let attachments = extensionItem.attachments else { + addDebugMessage("No attachments found") + showError(message: "No attachments found") + return } + addDebugMessage("Found \(attachments.count) attachments") + + // Process each attachment + for attachment in attachments { + // Log attachment types + let typeIdentifiers = attachment.registeredTypeIdentifiers + addDebugMessage("Attachment types: \(typeIdentifiers.joined(separator: ", "))") + + // Check for URLs first (for web links to GIFs) + if attachment.hasItemConformingToTypeIdentifier(UTType.url.identifier) { + addDebugMessage("Processing URL type attachment") + attachment.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { + [weak self] item, error in + guard let self = self else { return } + + if let url = item as? URL, url.absoluteString.lowercased().hasSuffix(".gif") { + DispatchQueue.main.async { + self.receivedURL = url + self.originalURL = url.absoluteString + self.updateUI() + } + } else if let url = item as? URL { + // It's a URL but not directly to a GIF, might be a webpage containing a GIF + DispatchQueue.main.async { + self.originalURL = url.absoluteString + self.receivedURL = url + self.updateUI() + self.addDebugMessage("URL processed: \(url.absoluteString)") + } + } + } + continue + } + + // Check for GIF files + if attachment.hasItemConformingToTypeIdentifier("com.compuserve.gif") + || attachment.hasItemConformingToTypeIdentifier("public.gif") + { + addDebugMessage("Processing GIF type attachment") + attachment.loadItem(forTypeIdentifier: "com.compuserve.gif", options: nil) { + [weak self] item, error in + guard let self = self else { return } + + if let url = item as? URL { + DispatchQueue.main.async { + self.receivedURL = url + self.originalURL = url.absoluteString + self.loadGIFData(from: url) + self.updateUI() + } + } else if let data = item as? Data { + DispatchQueue.main.async { + self.gifData = data + self.updateUI() + } + } + } + continue + } + + // Check for images that might be GIFs + if attachment.hasItemConformingToTypeIdentifier(UTType.image.identifier) { + attachment.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { + [weak self] item, error in + guard let self = self else { return } + + if let url = item as? URL, url.pathExtension.lowercased() == "gif" { + DispatchQueue.main.async { + self.receivedURL = url + self.originalURL = url.absoluteString + self.loadGIFData(from: url) + self.updateUI() + } + } else if let data = item as? Data, self.isGIFData(data) { + DispatchQueue.main.async { + self.gifData = data + self.updateUI() + } + } + } + } + } + } + + private func updateUI() { + // Enable the Post button if we have a URL or data + navigationItem.rightBarButtonItem?.isEnabled = (receivedURL != nil || gifData != nil) + + // Update content area + if let url = receivedURL { + let displayText = "GIF from: \(url.host ?? "URL")" + if contentText.isEmpty { + + } + } + } + + private func loadGIFData(from url: URL) { + do { + let data = try Data(contentsOf: url) + if isGIFData(data) { + gifData = data + } + } catch { + print("Error loading GIF data: \(error)") + } + } + + private func isGIFData(_ data: Data) -> Bool { + // Simple check for GIF file signature: "GIF87a" or "GIF89a" + guard data.count > 6 else { return false } + + let header = data.prefix(6) + let gif87a = Data([0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) // "GIF87a" + let gif89a = Data([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]) // "GIF89a" + + return header == gif87a || header == gif89a + } + + private func downloadGIF(from url: URL, completion: @escaping (Data?, Error?) -> Void) { + URLSession.shared.dataTask(with: url) { data, response, error in + DispatchQueue.main.async { + if let data = data, self.isGIFData(data) { + completion(data, nil) + } else { + completion( + nil, + error + ?? NSError( + domain: "GIFShare", code: -1, + userInfo: [NSLocalizedDescriptionKey: "Invalid GIF data"])) + } + } + }.resume() + } + + private func saveGIF() { + guard let gifData = gifData else { + showError(message: "No GIF data to save") + return + } + + addDebugMessage("Starting to save GIF, size: \(gifData.count) bytes") + + // Save the GIF using the shared App Group container + let userDefaults = UserDefaults(suiteName: appGroupID) + let fileManager = FileManager.default + + // IMPORTANT: Use the same path conventions as GIFFileManager + guard + let containerURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: appGroupID) + else { + showError(message: "Could not access shared container") + return + } + + // Use the same folder name as in GIFFileManager + let gifsFolder = containerURL.appendingPathComponent("SharedGIFs", isDirectory: true) + + // Create GIFs directory if needed + if !fileManager.fileExists(atPath: gifsFolder.path) { + do { + try fileManager.createDirectory(at: gifsFolder, withIntermediateDirectories: true) + addDebugMessage("Created shared GIFs directory") + } catch { + showError(message: "Could not create GIFs directory: \(error.localizedDescription)") + return + } + } + + // Create a unique filename using the same pattern as GIFFileManager + let urlHash = originalURL.hashValue + let timestamp = Int(Date().timeIntervalSince1970) + let filename = "gif_\(urlHash)_\(timestamp).gif" + let fileURL = gifsFolder.appendingPathComponent(filename) + + // Save the GIF file + do { + try gifData.write(to: fileURL) + addDebugMessage("GIF saved to: \(fileURL.path)") + + // Create a UUID that will be used consistently across the app + let gifId = UUID() + + // Store GIF entry in UserDefaults to notify main app + let gifInfo: [String: Any] = [ + "localFilePath": fileURL.path, + "originalURL": originalURL, + "createdAt": Date().timeIntervalSince1970, + "id": gifId.uuidString, + ] + + // Add to pending GIFs list (using the same key as in GIFStorageService) + var pendingGIFs = userDefaults?.array(forKey: pendingGIFsKey) as? [[String: Any]] ?? [] + pendingGIFs.append(gifInfo) + userDefaults?.set(pendingGIFs, forKey: pendingGIFsKey) + + // Force synchronize to ensure changes are visible immediately + userDefaults?.synchronize() + addDebugMessage("GIF info saved to UserDefaults") + + // Success + completeRequest() + + } catch { + showError(message: "Error saving GIF: \(error.localizedDescription)") + } + } + + private func addDebugMessage(_ message: String) { + let timestamp = DateFormatter.localizedString( + from: Date(), dateStyle: .none, timeStyle: .medium) + debugMessages.append("[\(timestamp)] \(message)") + print("GIFCollector Debug: \(message)") + } + + @objc private func showDebugInfo() { + let debugText = debugMessages.joined(separator: "\n\n") + let alert = UIAlertController( + title: "Debug Information", message: debugText, preferredStyle: .alert) + alert.addAction( + UIAlertAction(title: "Copy", style: .default) { _ in + UIPasteboard.general.string = debugText + }) + alert.addAction(UIAlertAction(title: "OK", style: .cancel)) + present(alert, animated: true) + } + + private func showError(message: String) { + addDebugMessage("ERROR: \(message)") + let alert = UIAlertController(title: "Error", message: message, preferredStyle: .alert) + alert.addAction( + UIAlertAction(title: "OK", style: .default) { [weak self] _ in + self?.completeRequest() + }) + + // Add debug info button + #if DEBUG + alert.addAction( + UIAlertAction(title: "Debug Info", style: .default) { [weak self] _ in + self?.showDebugInfo() + }) + #endif + + present(alert, animated: true) + } + + private func completeRequest() { + addDebugMessage("Completing extension request") + extensionContext?.completeRequest(returningItems: [], completionHandler: nil) + } }