feat(ios): use UUID-based filenames for shared images (Phase 1B)

Store shared images as <shareId>.<ext> in the App Group container while
keeping the original filename in metadata, preventing on-disk collisions
without changing retrieval, deletion, or JS consumer behavior.
This commit is contained in:
Jose Olarte III
2026-06-23 19:37:11 +08:00
parent 35a6a6bfb3
commit ddbd07f315
3 changed files with 80 additions and 21 deletions

View File

@@ -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 (`<shareId>.<ext>`) |
| `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}` (`<shareId>.<ext>`) |
---
@@ -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: `<shareId>.<extension>`, 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 `<shareId>.<ext>` |
| `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=<id> originalFilename=<name> storedFilename=<shareId>.<ext>
[ShareTarget] metadata stored shareId=<id> originalFilename=<name> storedFilename=<shareId>.<ext>
[ShareTarget] share retrieved shareId=<id> originalFilename=<name> storedFilename=<shareId>.<ext>
```
### 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 |

View File

@@ -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()

View File

@@ -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: <shareId>.<extension>
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
}