feat(ios): make shared image retrieval non-destructive (Phase 1C)

Stop deleting App Group metadata and image files in getSharedImageData()
so retrieval is read-only while preserving the existing plugin API shape.
Document removed deletion paths in the iOS share target audit.
This commit is contained in:
Jose Olarte III
2026-06-24 16:49:45 +08:00
parent ddbd07f315
commit 79ceebbd1d
4 changed files with 91 additions and 31 deletions

View File

@@ -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=<id>`:
| 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=<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>
[ShareTarget] shareId=<id> retrieved
[ShareTarget] shareId=<id> 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=<id> retrieved
[ShareTarget] shareId=<id> 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 |

View File

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

View File

@@ -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]
}

View File

@@ -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<SharedImageResult | null>;