diff --git a/ios/App/App/SharedImageUtility.swift b/ios/App/App/SharedImageUtility.swift index 6532e51c..11399af4 100644 --- a/ios/App/App/SharedImageUtility.swift +++ b/ios/App/App/SharedImageUtility.swift @@ -2,7 +2,8 @@ // SharedImageUtility.swift // App // -// Shared utility for accessing shared image data from App Group UserDefaults +// Shared utility for accessing shared image data from App Group container +// Images are stored as files in the App Group container to avoid UserDefaults size limits // Used by both AppDelegate and SharedImagePlugin // @@ -10,12 +11,18 @@ import Foundation public class SharedImageUtility { private static let appGroupIdentifier = "group.app.timesafari" - private static let sharedPhotoBase64Key = "sharedPhotoBase64" private static let sharedPhotoFileNameKey = "sharedPhotoFileName" + private static let sharedPhotoFilePathKey = "sharedPhotoFilePath" private static let sharedPhotoReadyKey = "sharedPhotoReady" + /// Get the App Group container URL for accessing shared files + private static var appGroupContainerURL: URL? { + return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) + } + /** - * Get shared image data from App Group UserDefaults + * Get shared image data from App Group container file + * All images are stored as files for consistency and to avoid UserDefaults size limits * Clears the data after reading to prevent re-reading * * @returns Dictionary with "base64" and "fileName" keys, or nil if no shared image @@ -25,31 +32,49 @@ public class SharedImageUtility { return nil } - guard let base64 = userDefaults.string(forKey: sharedPhotoBase64Key), - let fileName = userDefaults.string(forKey: sharedPhotoFileNameKey) else { + // Get file path and filename from UserDefaults + guard let filePath = userDefaults.string(forKey: sharedPhotoFilePathKey), + let containerURL = appGroupContainerURL else { return nil } + let fileName = userDefaults.string(forKey: sharedPhotoFileNameKey) ?? "shared-image.jpg" + let fileURL = containerURL.appendingPathComponent(filePath) + + // Read image data from file + guard let imageData = try? Data(contentsOf: fileURL) else { + return nil + } + + // Convert file data to base64 for JavaScript consumption + let base64String = imageData.base64EncodedString() + // Clear the shared data after reading - userDefaults.removeObject(forKey: sharedPhotoBase64Key) + userDefaults.removeObject(forKey: sharedPhotoFilePathKey) userDefaults.removeObject(forKey: sharedPhotoFileNameKey) + + // Remove the file + try? FileManager.default.removeItem(at: fileURL) + userDefaults.synchronize() - return ["base64": base64, "fileName": fileName] + return ["base64": base64String, "fileName": fileName] } /** * Check if shared image exists without reading it * - * @returns true if shared image data exists, false otherwise + * @returns true if shared image file exists, false otherwise */ static func hasSharedImage() -> Bool { - guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else { + guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier), + let filePath = userDefaults.string(forKey: sharedPhotoFilePathKey), + let containerURL = appGroupContainerURL else { return false } - return userDefaults.string(forKey: sharedPhotoBase64Key) != nil && - userDefaults.string(forKey: sharedPhotoFileNameKey) != nil + let fileURL = containerURL.appendingPathComponent(filePath) + return FileManager.default.fileExists(atPath: fileURL.path) } /** diff --git a/ios/App/TimeSafariShareExtension/ShareViewController.swift b/ios/App/TimeSafariShareExtension/ShareViewController.swift index c793db1b..cd6c733a 100644 --- a/ios/App/TimeSafariShareExtension/ShareViewController.swift +++ b/ios/App/TimeSafariShareExtension/ShareViewController.swift @@ -11,8 +11,14 @@ import UniformTypeIdentifiers class ShareViewController: UIViewController { private let appGroupIdentifier = "group.app.timesafari" - private let sharedPhotoBase64Key = "sharedPhotoBase64" private let sharedPhotoFileNameKey = "sharedPhotoFileName" + private let sharedPhotoFilePathKey = "sharedPhotoFilePath" + private let sharedImageFileName = "shared-image.jpg" + + /// Get the App Group container URL for storing shared files + private var appGroupContainerURL: URL? { + return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) + } override func viewDidLoad() { super.viewDidLoad() @@ -66,13 +72,56 @@ class ShareViewController: UIViewController { for attachment in attachments { if attachment.hasItemConformingToTypeIdentifier(UTType.image.identifier) { + // First, try to load as UIImage directly (best for HEIF and other formats) + // This uses the system's built-in image decoding which handles HEIF automatically + if attachment.canLoadObject(ofClass: UIImage.self) { + attachment.loadObject(ofClass: UIImage.self) { [weak self] (image, error) in + guard let self = self else { + completion(false) + return + } + + if let error = error { + completion(false) + return + } + + guard let image = image as? UIImage else { + completion(false) + return + } + + // Convert to JPEG for web compatibility + guard let imageData = image.jpegData(compressionQuality: 0.9) else { + completion(false) + return + } + + // Get filename from attachment if available + var fileName = "shared-image.jpg" + if let identifier = attachment.suggestedName { + let nameWithoutExt = (identifier as NSString).deletingPathExtension + fileName = "\(nameWithoutExt).jpg" + } + + // Store image as file in App Group container + if self.storeImageData(imageData, fileName: fileName) { + completion(true) + } else { + completion(false) + } + } + return // Process only the first image + } + + // Fallback: load as data/URL (for formats that don't support UIImage loading) attachment.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { [weak self] (data, error) in guard let self = self else { completion(false) return } - if error != nil { + if let error = error { completion(false) return } @@ -83,15 +132,39 @@ class ShareViewController: UIViewController { if let url = data as? URL { // Image provided as file URL - imageData = try? Data(contentsOf: url) - fileName = url.lastPathComponent - } else if let image = data as? UIImage { - // Image provided as UIImage - imageData = image.jpegData(compressionQuality: 0.9) - fileName = "shared-image.jpg" + // For security-scoped resources, we need to access them properly + let accessing = url.startAccessingSecurityScopedResource() + defer { + if accessing { + url.stopAccessingSecurityScopedResource() + } + } + + // Try loading as UIImage first (handles HEIF, RAW, etc.) + if let image = UIImage(contentsOfFile: url.path) { + // Convert to JPEG for web compatibility + imageData = image.jpegData(compressionQuality: 0.9) + // Preserve original filename but change extension to .jpg + let originalName = url.lastPathComponent + if let nameWithoutExt = originalName.components(separatedBy: ".").first { + fileName = "\(nameWithoutExt).jpg" + } else { + fileName = "shared-image.jpg" + } + } else { + // Fallback: try reading raw data (for already web-compatible formats) + imageData = try? Data(contentsOf: url) + fileName = url.lastPathComponent + } } else if let data = data as? Data { // Image provided as raw Data - imageData = data + // Try to load as UIImage first to handle HEIF/RAW formats + if let image = UIImage(data: data) { + imageData = image.jpegData(compressionQuality: 0.9) + } else { + // Fallback: use raw data (assumes already web-compatible) + imageData = data + } fileName = "shared-image.jpg" } @@ -100,20 +173,12 @@ class ShareViewController: UIViewController { return } - // Convert to base64 - let base64String = finalImageData.base64EncodedString() - - // Store in App Group UserDefaults - guard let userDefaults = UserDefaults(suiteName: self.appGroupIdentifier) else { + // Store image as file in App Group container + if self.storeImageData(finalImageData, fileName: fileName) { + completion(true) + } else { completion(false) - return } - - userDefaults.set(base64String, forKey: self.sharedPhotoBase64Key) - userDefaults.set(fileName, forKey: self.sharedPhotoFileNameKey) - userDefaults.synchronize() - - completion(true) } return // Process only the first image } @@ -124,6 +189,43 @@ class ShareViewController: UIViewController { completion(false) } + /// Store image data as a file in the App Group container + /// All images are stored as files regardless of size for consistency and simplicity + /// Returns true if successful, false otherwise + private func storeImageData(_ imageData: Data, fileName: String) -> Bool { + guard let containerURL = appGroupContainerURL else { + return false + } + + // Create file URL in the container + let fileURL = containerURL.appendingPathComponent(sharedImageFileName) + + // Remove old file if it exists + try? FileManager.default.removeItem(at: fileURL) + + // Write image data to file + do { + try imageData.write(to: fileURL) + } catch { + return false + } + + // Store file path and filename in UserDefaults (small data, safe to store) + guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else { + return false + } + + // Store relative path and filename + userDefaults.set(sharedImageFileName, forKey: sharedPhotoFilePathKey) + userDefaults.set(fileName, forKey: sharedPhotoFileNameKey) + + // Clean up any old base64 data that might exist + userDefaults.removeObject(forKey: "sharedPhotoBase64") + + userDefaults.synchronize() + return true + } + private func openMainApp() { // Open the main app with minimal URL - app will detect shared data on activation guard let url = URL(string: "timesafari://") else { diff --git a/src/views/SharedPhotoView.vue b/src/views/SharedPhotoView.vue index e9a33751..78d7d7d7 100644 --- a/src/views/SharedPhotoView.vue +++ b/src/views/SharedPhotoView.vue @@ -196,13 +196,14 @@ export default class SharedPhotoView extends Vue { /** * Watches for route query changes to reload image when navigating - * to the same route with different query parameters (e.g., new fileName) + * to the same route with different query parameters (e.g., new fileName or _refresh) + * This handles both new navigations and refreshes when already on the route */ - @Watch("$route.query.fileName") - async onFileNameChange(newFileName: string | undefined) { - if (newFileName) { - await this.loadSharedImage(); - } + @Watch("$route.query", { deep: true }) + async onRouteQueryChange() { + // Reload image when any query parameter changes (fileName or _refresh) + // This ensures the image refreshes when a new image is shared while already on this view + await this.loadSharedImage(); } /**