From 0c66142093e993fcf7732b881f58c29b55d826c1 Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Thu, 4 Dec 2025 20:39:26 +0800 Subject: [PATCH] Fix iOS share extension large image handling - Fix large images not loading from share sheet (exceeded UserDefaults 4MB limit) - Store all shared images as files in App Group container instead of base64 in UserDefaults - Fix image refresh when sharing new image while already on SharedPhotoView - Add route query watcher to detect _refresh parameter changes - Remove debug logging and redundant UIImage check The previous implementation tried to store base64-encoded images in UserDefaults, which failed for large images (HEIF images were often 6MB+ when base64 encoded). Now all images are stored as files in the App Group container, with only the file path stored in UserDefaults. This supports images of any size and provides consistent behavior regardless of format. Also fixes an issue where sharing a new image while already viewing SharedPhotoView wouldn't refresh the displayed image. --- ios/App/App/SharedImageUtility.swift | 47 ++++-- .../ShareViewController.swift | 144 +++++++++++++++--- src/views/SharedPhotoView.vue | 13 +- 3 files changed, 166 insertions(+), 38 deletions(-) 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(); } /**