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.
This commit is contained in:
Jose Olarte III
2025-12-04 20:39:26 +08:00
parent 84983ee10b
commit 0c66142093
3 changed files with 166 additions and 38 deletions

View File

@@ -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)
}
/**

View File

@@ -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 {

View File

@@ -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();
}
/**