From ddbd07f315690624102e4d526d7e9e9d9e978453 Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Tue, 23 Jun 2026 19:37:11 +0800 Subject: [PATCH] feat(ios): use UUID-based filenames for shared images (Phase 1B) Store shared images as . in the App Group container while keeping the original filename in metadata, preventing on-disk collisions without changing retrieval, deletion, or JS consumer behavior. --- doc/share-target-ios-audit.md | 51 +++++++++++++++++-- ios/App/App/SharedImageUtility.swift | 4 +- .../ShareViewController.swift | 46 +++++++++++------ 3 files changed, 80 insertions(+), 21 deletions(-) diff --git a/doc/share-target-ios-audit.md b/doc/share-target-ios-audit.md index de724d60..2eb20de6 100644 --- a/doc/share-target-ios-audit.md +++ b/doc/share-target-ios-audit.md @@ -10,12 +10,12 @@ The iOS share target uses a **Share Extension** (`TimeSafariShareExtension`) tha | Key | Storage | Written by | Purpose | |-----|---------|------------|---------| -| `sharedPhotoFilePath` | UserDefaults (suite) | Share Extension | Relative filename of image file in container | -| `sharedPhotoFileName` | UserDefaults (suite) | Share Extension | Display/original filename | +| `sharedPhotoFilePath` | UserDefaults (suite) | Share Extension | On-disk filename in container (`.`) | +| `sharedPhotoFileName` | UserDefaults (suite) | Share Extension | Original display filename from source app | | `sharedPhotoShareId` | UserDefaults (suite) | Share Extension | Unique UUID per incoming share (Phase 1A) | | `sharedPhotoReady` | UserDefaults (suite) | Share Extension | Boolean signal that a new share is available | | `sharedPhotoBase64` | UserDefaults (suite) | *(legacy, not written)* | Removed on write for cleanup | -| Image file | App Group filesystem | Share Extension | Raw image bytes at `{container}/{sharedPhotoFilePath}` | +| Image file | App Group filesystem | Share Extension | Raw image bytes at `{container}/{sharedPhotoFilePath}` (`.`) | --- @@ -324,6 +324,51 @@ Example log sequence for a single share: --- +## Unique Stored Filenames + +**Implemented:** 2026-06-23 (Phase 1B) + +Phase 1B eliminates on-disk filename collisions by storing each shared image under a UUID-based filename while preserving the original filename as metadata for consumers. + +### On-Disk vs Metadata + +| Field | UserDefaults key | Example | Purpose | +|-------|------------------|---------|---------| +| Stored filename | `sharedPhotoFilePath` | `A1B2C3D4-E5F6-7890-ABCD-EF1234567890.jpg` | Unique file in App Group container | +| Original filename | `sharedPhotoFileName` | `vacation-photo.jpg` | Returned to JS as `fileName` | +| Share ID | `sharedPhotoShareId` | `A1B2C3D4-E5F6-7890-ABCD-EF1234567890` | Correlates logs across extension and main app | + +Stored filename format: `.`, where extension is taken from the original filename (defaults to `jpg` when absent). + +### Implementation + +| File | Method | Change | +|------|--------|--------| +| `ShareViewController.swift` | `fileExtension(from:)` | Extracts extension from original filename | +| `ShareViewController.swift` | `storedFileName(shareId:originalFileName:)` | Builds `.` | +| `ShareViewController.swift` | `storeImageData` | Writes to stored filename; saves original in `sharedPhotoFileName` | +| `SharedImageUtility.swift` | `getSharedImageData` | Reads file via `sharedPhotoFilePath`; returns original `sharedPhotoFileName` | + +When a new share arrives before the previous one is retrieved, `storeImageData` removes the file at the previous `sharedPhotoFilePath` before writing, preserving single-pending-share semantics. + +### Logging (Phase 1B) + +Store and retrieve events include all three identifiers: + +``` +[ShareTarget] file stored shareId= originalFilename= storedFilename=. +[ShareTarget] metadata stored shareId= originalFilename= storedFilename=. +[ShareTarget] share retrieved shareId= originalFilename= storedFilename=. +``` + +### Phase 1B Scope (Intentionally Unchanged) + +- `getSharedImageData()` still returns only `base64` and original `fileName` to JavaScript +- Retrieval timing, deletion keys, and `hasSharedImage()` behavior are unchanged +- Android code is unchanged + +--- + ## Configuration References | Resource | Value | diff --git a/ios/App/App/SharedImageUtility.swift b/ios/App/App/SharedImageUtility.swift index 1c524cba..c5a7c582 100644 --- a/ios/App/App/SharedImageUtility.swift +++ b/ios/App/App/SharedImageUtility.swift @@ -48,9 +48,7 @@ public class SharedImageUtility { return nil } - if let shareId = shareId { - print("[ShareTarget] share retrieved shareId=\(shareId)") - } + print("[ShareTarget] share retrieved shareId=\(shareId ?? "unknown") originalFilename=\(fileName) storedFilename=\(filePath)") // Convert file data to base64 for JavaScript consumption let base64String = imageData.base64EncodedString() diff --git a/ios/App/TimeSafariShareExtension/ShareViewController.swift b/ios/App/TimeSafariShareExtension/ShareViewController.swift index 4e27880f..eb667b48 100644 --- a/ios/App/TimeSafariShareExtension/ShareViewController.swift +++ b/ios/App/TimeSafariShareExtension/ShareViewController.swift @@ -149,7 +149,18 @@ class ShareViewController: UIViewController { } return "shared-image.\(newExtension)" } - + + /// Extract file extension from original filename, defaulting to jpg when absent + private func fileExtension(from fileName: String) -> String { + let ext = (fileName as NSString).pathExtension + return ext.isEmpty ? "jpg" : ext.lowercased() + } + + /// Build unique on-disk filename: . + private func storedFileName(shareId: String, originalFileName: String) -> String { + return "\(shareId).\(fileExtension(from: originalFileName))" + } + /// 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 @@ -157,38 +168,43 @@ class ShareViewController: UIViewController { guard let containerURL = appGroupContainerURL else { return false } - - // Create file URL in the container using the actual filename - // Extract extension from fileName if present, otherwise use sharedImageFileName - let actualFileName = fileName.isEmpty ? sharedImageFileName : fileName - let fileURL = containerURL.appendingPathComponent(actualFileName) - - // Remove old file if it exists - try? FileManager.default.removeItem(at: fileURL) - + + let originalFileName = fileName.isEmpty ? "\(sharedImageFileName).jpg" : fileName + let storedFileName = storedFileName(shareId: shareId, originalFileName: originalFileName) + let fileURL = containerURL.appendingPathComponent(storedFileName) + + // Remove previously pending share file (metadata tracks one share at a time) + if let userDefaults = UserDefaults(suiteName: appGroupIdentifier), + let previousPath = userDefaults.string(forKey: sharedPhotoFilePathKey) { + let previousURL = containerURL.appendingPathComponent(previousPath) + if previousURL != fileURL { + try? FileManager.default.removeItem(at: previousURL) + } + } + // Write image data to file do { try imageData.write(to: fileURL) } catch { return false } - print("[ShareTarget] file stored shareId=\(shareId)") + print("[ShareTarget] file stored shareId=\(shareId) originalFilename=\(originalFileName) storedFilename=\(storedFileName)") // Store file path and filename in UserDefaults (small data, safe to store) guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else { return false } - // Store relative path, filename, and share identifier - userDefaults.set(actualFileName, forKey: sharedPhotoFilePathKey) - userDefaults.set(fileName, forKey: sharedPhotoFileNameKey) + // sharedPhotoFilePath = on-disk name; sharedPhotoFileName = original display name + userDefaults.set(storedFileName, forKey: sharedPhotoFilePathKey) + userDefaults.set(originalFileName, forKey: sharedPhotoFileNameKey) userDefaults.set(shareId, forKey: sharedPhotoShareIdKey) // Clean up any old base64 data that might exist userDefaults.removeObject(forKey: "sharedPhotoBase64") userDefaults.synchronize() - print("[ShareTarget] metadata stored shareId=\(shareId)") + print("[ShareTarget] metadata stored shareId=\(shareId) originalFilename=\(originalFileName) storedFilename=\(storedFileName)") return true }