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:
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user