diff --git a/doc/share-target-ios-audit.md b/doc/share-target-ios-audit.md index 2eb20de6..035f2573 100644 --- a/doc/share-target-ios-audit.md +++ b/doc/share-target-ios-audit.md @@ -61,7 +61,7 @@ External App (Photos, Safari, etc.) │ SharedImagePlugin.getSharedImage() │ │ → SharedImageUtility │ │ .getSharedImageData() │ -│ (reads file, deletes native data) │ +│ (read-only; leaves native data intact) │ └─────────────────────────────────────┘ │ ▼ @@ -103,7 +103,7 @@ External App (Photos, Safari, etc.) | File | Method | Responsibility | |------|--------|----------------| -| `ios/App/App/SharedImageUtility.swift` | `getSharedImageData()` | Reads file from App Group, returns base64 + fileName; **deletes metadata and file** | +| `ios/App/App/SharedImageUtility.swift` | `getSharedImageData()` | Read-only: reads file from App Group, returns base64 + fileName; leaves metadata and file intact (Phase 1C) | | | `hasSharedImage()` | Non-destructive existence check (metadata + file on disk) | | | `isSharedPhotoReady()` | Reads `sharedPhotoReady` flag | | | `clearSharedPhotoReadyFlag()` | Removes `sharedPhotoReady` key | @@ -171,16 +171,29 @@ External App (Photos, Safari, etc.) | File | Method | Keys Removed | |------|--------|--------------| | `ShareViewController.swift` | `storeImageData(_:fileName:)` | `sharedPhotoBase64` (legacy cleanup) | -| `SharedImageUtility.swift` | `getSharedImageData()` | `sharedPhotoFilePath`, `sharedPhotoFileName` | | `SharedImageUtility.swift` | `clearSharedPhotoReadyFlag()` | `sharedPhotoReady` | | `AppDelegate.swift` | `checkForSharedImageOnActivation()` | `sharedPhotoReady` (via `clearSharedPhotoReadyFlag()`) | +**Removed in Phase 1C** (previously in `SharedImageUtility.getSharedImageData()`): + +| Keys / files | Mechanism | +|--------------|-----------| +| `sharedPhotoFilePath` | `userDefaults.removeObject(forKey:)` | +| `sharedPhotoFileName` | `userDefaults.removeObject(forKey:)` | +| Image file at `sharedPhotoFilePath` | `FileManager.removeItem(at:)` | +| `userDefaults.synchronize()` after deletion | Called after metadata/file removal | + ### Deletes — Shared Image Files (App Group Container) | File | Method | Details | |------|--------|---------| -| `ShareViewController.swift` | `storeImageData(_:fileName:)` | Removes existing file at target path before write | -| `SharedImageUtility.swift` | `getSharedImageData()` | `FileManager.removeItem(at: fileURL)` after successful read | +| `ShareViewController.swift` | `storeImageData(_:fileName:shareId:)` | Removes previous pending share file at prior `sharedPhotoFilePath` before write | + +**Removed in Phase 1C** (previously in `SharedImageUtility.getSharedImageData()`): + +| Operation | Mechanism | +|-----------|-----------| +| Delete image file after successful read | `FileManager.removeItem(at: fileURL)` | ### Secondary Storage (Post-Native Consumption) @@ -230,11 +243,16 @@ After native read, image data lives in SQLite `temp` table under key `shared-pho | When | File | Method | What is deleted | |------|------|--------|-----------------| -| Before overwrite | `ShareViewController.swift` | `storeImageData` | Existing file at same filename | +| Before new share write | `ShareViewController.swift` | `storeImageData` | Previous pending share file at prior `sharedPhotoFilePath` | | Legacy cleanup on write | `ShareViewController.swift` | `storeImageData` | `sharedPhotoBase64` UserDefaults key | -| On successful read | `SharedImageUtility.swift` | `getSharedImageData` | `sharedPhotoFilePath`, `sharedPhotoFileName`, image file | | On app activation (flag only) | `AppDelegate.swift` | `checkForSharedImageOnActivation` | `sharedPhotoReady` flag | +**Removed in Phase 1C** (no longer deleted on retrieve): + +| When | File | Method | What was deleted | +|------|------|--------|------------------| +| On successful read | `SharedImageUtility.swift` | `getSharedImageData` | `sharedPhotoFilePath`, `sharedPhotoFileName`, image file | + ### Temp Database (JS) | When | File | Method | What is deleted | @@ -242,13 +260,13 @@ After native read, image data lives in SQLite `temp` table under key `shared-pho | Before storing new share | `main.capacitor.ts` | `storeSharedImageInTempDB` | Prior `shared-photo-base64` temp row | | After view loads image | `SharedPhotoView.vue` | `loadSharedImage` | `shared-photo-base64` temp row | -**Important:** Native image file and metadata survive until `getSharedImageData()` succeeds. The `sharedPhotoReady` flag is cleared independently and earlier by `AppDelegate`. +**Important:** Native image file and metadata persist after `getSharedImageData()` (Phase 1C). Cleanup is deferred to a later phase. The `sharedPhotoReady` flag is still cleared independently by `AppDelegate` on activation. --- ## Potential Race Conditions -1. **Multiple JS detection paths, single destructive read.** `applicationDidBecomeActive`, the 1000ms startup timer, `appStateChange`, and `appUrlOpen` can all invoke `checkAndStoreNativeSharedImage()` close together. `getSharedImageData()` deletes the file and metadata on first successful read; subsequent calls return null. The `isProcessingSharedImage` JS lock reduces but does not eliminate races between separate async entry points that start before the lock is set. +1. **Multiple JS detection paths, repeatable native read.** `applicationDidBecomeActive`, the 1000ms startup timer, `appStateChange`, and `appUrlOpen` can all invoke `checkAndStoreNativeSharedImage()` close together. Since Phase 1C, `getSharedImageData()` is read-only and returns the same data on every call until a new share overwrites metadata or explicit cleanup is added. The `isProcessingSharedImage` JS lock still reduces duplicate temp-DB writes and navigations. 2. **Deep-link listener registered 2 seconds after mount.** The share extension opens `timesafari://` immediately. If Capacitor does not buffer the launch URL until the `appUrlOpen` listener is registered (at T+2000ms), the deep-link path may be missed on cold start. The 1000ms startup check and `appStateChange` paths partially compensate. @@ -264,7 +282,7 @@ After native read, image data lives in SQLite `temp` table under key `shared-pho 8. **Stale comment implies polling that does not exist.** `handleDeepLink` comments reference internal polling in `checkAndStoreNativeSharedImage`, but no retry loop exists. A single failed read at the wrong moment is not retried on iOS (unlike Android's multi-delay startup checks). -9. **`hasSharedImage()` unused.** A non-destructive pre-check is available natively but JS always calls destructive `getSharedImage()` directly, making timing-sensitive false negatives more likely if called before the extension finishes writing. +9. **`hasSharedImage()` unused.** A non-destructive pre-check is available natively but JS always calls `getSharedImage()` directly. Since Phase 1C both methods are non-destructive on the native layer. --- @@ -292,21 +310,22 @@ All log lines use the prefix `[ShareTarget]` and include `shareId=`: | share received | `ShareViewController.swift` | `processSharedImage` | UUID generated before `loadItem` | | file stored | `ShareViewController.swift` | `storeImageData` | After successful `imageData.write(to:)` | | metadata stored | `ShareViewController.swift` | `storeImageData` | After UserDefaults `synchronize()` | -| share retrieved | `SharedImageUtility.swift` | `getSharedImageData` | After successful file read (only if `sharedPhotoShareId` is present) | +| share retrieved | `SharedImageUtility.swift` | `getSharedImageData` | After successful file read (Phase 1C log format) | Example log sequence for a single share: ``` [ShareTarget] share received shareId=A1B2C3D4-E5F6-7890-ABCD-EF1234567890 -[ShareTarget] file stored shareId=A1B2C3D4-E5F6-7890-ABCD-EF1234567890 -[ShareTarget] metadata stored shareId=A1B2C3D4-E5F6-7890-ABCD-EF1234567890 -[ShareTarget] share retrieved shareId=A1B2C3D4-E5F6-7890-ABCD-EF1234567890 +[ShareTarget] file stored shareId=A1B2C3D4-E5F6-7890-ABCD-EF1234567890 originalFilename=vacation.jpg storedFilename=A1B2C3D4-E5F6-7890-ABCD-EF1234567890.jpg +[ShareTarget] metadata stored shareId=A1B2C3D4-E5F6-7890-ABCD-EF1234567890 originalFilename=vacation.jpg storedFilename=A1B2C3D4-E5F6-7890-ABCD-EF1234567890.jpg +[ShareTarget] shareId=A1B2C3D4-E5F6-7890-ABCD-EF1234567890 retrieved +[ShareTarget] shareId=A1B2C3D4-E5F6-7890-ABCD-EF1234567890 left intact after retrieval ``` ### Phase 1A Scope (Intentionally Unchanged) - `getSharedImageData()` still returns only `base64` and `fileName` to JavaScript -- `sharedPhotoShareId` is **not** deleted on retrieve (deletion deferred to a later phase) +- `sharedPhotoShareId` is not deleted on retrieve (cleanup deferred to a later phase) - `hasSharedImage()`, `isSharedPhotoReady()`, and JS consumption paths are unchanged - Android code is unchanged @@ -358,17 +377,65 @@ 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=. +[ShareTarget] shareId= retrieved +[ShareTarget] shareId= left intact after retrieval ``` ### 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 --- +## Non-Destructive Retrieval + +**Implemented:** 2026-06-24 (Phase 1C) + +Phase 1C makes native shared-content retrieval read-only. `getSharedImageData()` and `SharedImagePlugin.getSharedImage()` no longer delete App Group metadata or image files after a successful read. Explicit cleanup is deferred to a later phase. + +### Behavior Change + +| Aspect | Before Phase 1C | After Phase 1C | +|--------|-----------------|----------------| +| `sharedPhotoFilePath` after retrieve | Removed | Retained | +| `sharedPhotoFileName` after retrieve | Removed | Retained | +| `sharedPhotoShareId` after retrieve | Retained (since Phase 1A) | Retained | +| Image file after retrieve | Deleted | Retained | +| Return value to JS | `{ base64, fileName }` | Unchanged | +| Repeat `getSharedImage()` calls | Return `null` after first success | Return same data until overwritten or cleaned up | + +### Logging + +After a successful read: + +``` +[ShareTarget] shareId= retrieved +[ShareTarget] shareId= left intact after retrieval +``` + +### Removed Deletion Paths + +All removal logic was in `SharedImageUtility.getSharedImageData()`: + +| # | What was deleted | Code removed | +|---|------------------|--------------| +| 1 | `sharedPhotoFilePath` UserDefaults key | `userDefaults.removeObject(forKey: sharedPhotoFilePathKey)` | +| 2 | `sharedPhotoFileName` UserDefaults key | `userDefaults.removeObject(forKey: sharedPhotoFileNameKey)` | +| 3 | Image file at `{container}/{sharedPhotoFilePath}` | `FileManager.default.removeItem(at: fileURL)` | +| 4 | Post-deletion UserDefaults flush | `userDefaults.synchronize()` after removals | + +`SharedImagePlugin.getSharedImage(_:)` delegated to `getSharedImageData()` and had no independent deletion logic. Comment updated to reflect read-only behavior. + +### Phase 1C Scope (Intentionally Unchanged) + +- No new cleanup or purge APIs added +- `clearSharedPhotoReadyFlag()` and share-extension write-side file removal unchanged +- JS temp DB deletion in `main.capacitor.ts` and `SharedPhotoView.vue` unchanged +- Android code unchanged + +--- + ## Configuration References | Resource | Value | diff --git a/ios/App/App/SharedImagePlugin.swift b/ios/App/App/SharedImagePlugin.swift index b1b29dec..a18c4b27 100644 --- a/ios/App/App/SharedImagePlugin.swift +++ b/ios/App/App/SharedImagePlugin.swift @@ -33,7 +33,7 @@ public class SharedImagePlugin: CAPPlugin, CAPBridgedPlugin { /** * Get shared image data from App Group UserDefaults * Returns base64 string and fileName, or null if no image exists - * Clears the data after reading to prevent re-reading + * Read-only: native metadata and file are left intact after retrieval (Phase 1C) */ @objc public func getSharedImage(_ call: CAPPluginCall) { guard let sharedData = SharedImageUtility.getSharedImageData() else { diff --git a/ios/App/App/SharedImageUtility.swift b/ios/App/App/SharedImageUtility.swift index c5a7c582..10d46ba2 100644 --- a/ios/App/App/SharedImageUtility.swift +++ b/ios/App/App/SharedImageUtility.swift @@ -24,7 +24,7 @@ public class SharedImageUtility { /** * 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 + * Read-only: metadata and file are left intact after retrieval (Phase 1C) * * @returns Dictionary with "base64" and "fileName" keys, or nil if no shared image */ @@ -48,20 +48,13 @@ public class SharedImageUtility { return nil } - print("[ShareTarget] share retrieved shareId=\(shareId ?? "unknown") originalFilename=\(fileName) storedFilename=\(filePath)") + let resolvedShareId = shareId ?? "unknown" + print("[ShareTarget] shareId=\(resolvedShareId) retrieved") + print("[ShareTarget] shareId=\(resolvedShareId) left intact after retrieval") // Convert file data to base64 for JavaScript consumption let base64String = imageData.base64EncodedString() - - // Clear the shared data after reading - userDefaults.removeObject(forKey: sharedPhotoFilePathKey) - userDefaults.removeObject(forKey: sharedPhotoFileNameKey) - - // Remove the file - try? FileManager.default.removeItem(at: fileURL) - - userDefaults.synchronize() - + return ["base64": base64String, "fileName": fileName] } diff --git a/src/plugins/definitions.ts b/src/plugins/definitions.ts index 7c7bbe75..f67a443c 100644 --- a/src/plugins/definitions.ts +++ b/src/plugins/definitions.ts @@ -11,7 +11,7 @@ export interface SharedImagePlugin { /** * Get shared image data from native layer * Returns base64 string and fileName, or null if no image exists - * Clears the data after reading to prevent re-reading + * Read-only on iOS: native metadata and file are left intact after retrieval (Phase 1C) */ getSharedImage(): Promise;