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.
25 KiB
iOS Share Target Implementation Audit
Generated: 2026-06-23 17:07:21 PST
Overview
The iOS share target uses a Share Extension (TimeSafariShareExtension) that writes a shared image to an App Group container (group.app.timesafari.share), then opens the main app via timesafari://. The main app reads the image through a native Capacitor plugin (SharedImagePlugin) and stores it in the JS temp database before routing to /shared-photo.
App Group Storage Model
| Key | Storage | Written by | Purpose |
|---|---|---|---|
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} (<shareId>.<ext>) |
End-to-End Flow
External App (Photos, Safari, etc.)
│
▼
┌─────────────────────────────────────┐
│ TimeSafariShareExtension │
│ ShareViewController │
│ 1. viewDidLoad → processAndOpenApp │
│ 2. processSharedImage (async) │
│ 3. storeImageData → file + metadata │
│ 4. setSharedPhotoReadyFlag │
│ 5. openMainApp (timesafari://) │
│ 6. completeRequest │
└─────────────────────────────────────┘
│
▼ App Group: group.app.timesafari.share
│ (UserDefaults keys + image file)
│
▼
┌─────────────────────────────────────┐
│ Main App (app.timesafari) │
│ │
│ Native detection: │
│ • AppDelegate.applicationDidBecome │
│ Active → checkForSharedImageOn │
│ Activation (flag only) │
│ • AppDelegate.application(open:) │
│ → Capacitor URL handling │
│ │
│ JS detection (main.capacitor.ts): │
│ • setTimeout 1000ms startup check │
│ • appStateChange (isActive) │
│ • appUrlOpen → timesafari:// │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ SharedImagePlugin.getSharedImage() │
│ → SharedImageUtility │
│ .getSharedImageData() │
│ (read-only; leaves native data intact) │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ main.capacitor.ts │
│ storeSharedImageInTempDB() │
│ → SQLite temp table │
│ → router.push/replace /shared-photo│
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ SharedPhotoView.vue │
│ loadSharedImage() │
│ → reads temp DB, deletes temp row │
│ → displays image, user action │
└─────────────────────────────────────┘
Component Responsibilities
Share Extension (Writer)
| File | Method | Responsibility |
|---|---|---|
ios/App/TimeSafariShareExtension/ShareViewController.swift |
viewDidLoad() |
Entry point; triggers share processing on load |
processAndOpenApp() |
Orchestrates image extraction, flag set, app open, extension completion | |
processSharedImage(from:completion:) |
Iterates NSExtensionItem attachments; loads first UTType.image via loadItem |
|
storeImageData(_:fileName:) |
Writes image file to App Group container; writes metadata to UserDefaults | |
setSharedPhotoReadyFlag() |
Sets sharedPhotoReady = true in App Group UserDefaults |
|
openMainApp() |
Opens timesafari:// via responder chain or extensionContext.open |
|
getFileNameWithExtension(_:newExtension:) |
Helper for PNG fallback filename | |
ios/App/TimeSafariShareExtension/Info.plist |
— | Declares share-services extension; accepts 1 image max |
ios/App/TimeSafariShareExtension/TimeSafariShareExtension.entitlements |
— | Grants App Group group.app.timesafari.share |
Main App Native Layer (Reader)
| File | Method | Responsibility |
|---|---|---|
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 |
|
ios/App/App/SharedImagePlugin.swift |
getSharedImage(_:) |
Capacitor bridge to getSharedImageData() |
hasSharedImage(_:) |
Capacitor bridge to hasSharedImage() |
|
ios/App/App/AppDelegate.swift |
application(_:didFinishLaunchingWithOptions:) |
Registers SharedImagePlugin with retry loop |
registerSharedImagePlugin() |
Manually registers plugin instance on Capacitor bridge | |
applicationDidBecomeActive(_:) |
Calls checkForSharedImageOnActivation() |
|
checkForSharedImageOnActivation() |
Checks ready flag, clears it, posts SharedPhotoReady NSNotification |
|
application(_:open:options:) |
Forwards URL opens (including timesafari://) to Capacitor |
|
ios/App/App/App.entitlements |
— | Grants App Group group.app.timesafari.share |
JavaScript Layer (Consumer)
| File | Method | Responsibility |
|---|---|---|
src/plugins/SharedImagePlugin.ts |
— | Registers Capacitor plugin name SharedImage |
src/plugins/definitions.ts |
— | TypeScript interface for getSharedImage / hasSharedImage |
src/main.capacitor.ts |
checkAndStoreNativeSharedImage() |
Calls SharedImage.getSharedImage(), stores in temp DB; guarded by isProcessingSharedImage lock |
storeSharedImageInTempDB() |
Clears old temp row, inserts base64 data URL into SQLite temp table |
|
checkForSharedImageAndNavigate() |
Checks native share, navigates to /shared-photo on success |
|
handleDeepLink() |
Handles timesafari:// empty-path URLs from share extension on iOS |
|
registerDeepLinkListener() |
Registers Capacitor appUrlOpen listener |
|
src/views/SharedPhotoView.vue |
mounted() / onRouteQueryChange() |
Loads image from temp DB for display |
loadSharedImage() |
Reads SHARED_PHOTO_BASE64_KEY from temp DB; deletes temp row after load |
|
src/router/index.ts |
— | Defines /shared-photo route |
src/libs/util.ts |
SHARED_PHOTO_BASE64_KEY |
Temp DB key constant ("shared-photo-base64") |
Read / Write / Delete Inventory
Writes — Shared Image Metadata (App Group UserDefaults)
| File | Method | Keys Written |
|---|---|---|
ShareViewController.swift |
storeImageData(_:fileName:) |
sharedPhotoFilePath, sharedPhotoFileName |
ShareViewController.swift |
setSharedPhotoReadyFlag() |
sharedPhotoReady (= true) |
Writes — Shared Image Files (App Group Container)
| File | Method | Details |
|---|---|---|
ShareViewController.swift |
storeImageData(_:fileName:) |
imageData.write(to:) at {containerURL}/{actualFileName} |
Reads — Shared Image Metadata (App Group UserDefaults)
| File | Method | Keys Read |
|---|---|---|
SharedImageUtility.swift |
getSharedImageData() |
sharedPhotoFilePath, sharedPhotoFileName |
SharedImageUtility.swift |
hasSharedImage() |
sharedPhotoFilePath |
SharedImageUtility.swift |
isSharedPhotoReady() |
sharedPhotoReady |
AppDelegate.swift |
checkForSharedImageOnActivation() |
sharedPhotoReady (via isSharedPhotoReady()) |
Reads — Shared Image Files (App Group Container)
| File | Method | Details |
|---|---|---|
ShareViewController.swift |
processSharedImage (URL path) |
Reads source image via Data(contentsOf: url) from security-scoped URL |
SharedImageUtility.swift |
getSharedImageData() |
Data(contentsOf: fileURL) from App Group container |
SharedImageUtility.swift |
hasSharedImage() |
FileManager.fileExists(atPath:) only (no data read) |
Deletes — Shared Image Metadata (App Group UserDefaults)
| File | Method | Keys Removed |
|---|---|---|
ShareViewController.swift |
storeImageData(_:fileName:) |
sharedPhotoBase64 (legacy cleanup) |
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: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)
After native read, image data lives in SQLite temp table under key shared-photo-base64:
| File | Method | Operation |
|---|---|---|
main.capacitor.ts |
storeSharedImageInTempDB() |
DELETE old row, then INSERT OR REPLACE |
SharedPhotoView.vue |
loadSharedImage() |
READ then DELETE temp row |
Timing, Delays, Retries, and Polling
| Location | Mechanism | Values | Purpose |
|---|---|---|---|
AppDelegate.swift didFinishLaunching |
Plugin registration retry loop | Initial delay 0.5s; up to 5 attempts; backoff 0.5s × attempt | Ensure Capacitor bridge exists before registering SharedImagePlugin |
main.capacitor.ts startup |
setTimeout |
1000ms (iOS only) | Deferred check for shared image on cold launch |
main.capacitor.ts startup |
setTimeout |
2000ms | Deferred registration of appUrlOpen deep-link listener |
main.capacitor.ts startup |
setTimeout |
1000ms | Log app initialization status |
main.capacitor.ts |
CapacitorApp.addListener("appStateChange") |
On every isActive === true |
Re-check shared image when app foregrounds |
main.capacitor.ts |
isProcessingSharedImage flag |
Synchronous JS lock | Prevents concurrent checkAndStoreNativeSharedImage() calls |
main.capacitor.ts |
Comment at line 214 | References polling | Stale comment — checkAndStoreNativeSharedImage() does not poll or retry |
No native polling or retry exists for reading App Group data. hasSharedImage() is exposed but never called from application JS code.
Current Startup Detection Points
| # | Layer | Trigger | File | Method | Action |
|---|---|---|---|---|---|
| 1 | Native | App becomes active (cold start + resume) | AppDelegate.swift |
applicationDidBecomeActive → checkForSharedImageOnActivation |
Reads sharedPhotoReady flag; clears flag; posts SharedPhotoReady NSNotification (no JS listener) |
| 2 | JS | Module load + 1000ms delay | main.capacitor.ts |
setTimeout → checkForSharedImageAndNavigate |
Calls SharedImage.getSharedImage(), stores in temp DB, navigates to /shared-photo |
| 3 | JS | App foreground | main.capacitor.ts |
appStateChange listener → checkForSharedImageAndNavigate |
Same as above |
| 4 | JS | Deep link timesafari:// |
main.capacitor.ts |
appUrlOpen → handleDeepLink → checkAndStoreNativeSharedImage |
iOS-only empty-path URL handling; navigates to /shared-photo |
| 5 | Native | URL open | AppDelegate.swift |
application(_:open:options:) |
Forwards to Capacitor ApplicationDelegateProxy (enables #4) |
| 6 | Native | Cold launch plugin setup | AppDelegate.swift |
didFinishLaunching → tryRegister |
Registers SharedImagePlugin (not a share check, but required for JS reads) |
Note: Detection points 1–4 can all fire for a single share event. Only the JS paths (#2–4) actually read and consume the image.
Current Deletion Points
App Group (Native)
| When | File | Method | What is deleted |
|---|---|---|---|
| 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 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 |
|---|---|---|---|
| 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 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
-
Multiple JS detection paths, repeatable native read.
applicationDidBecomeActive, the 1000ms startup timer,appStateChange, andappUrlOpencan all invokecheckAndStoreNativeSharedImage()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. TheisProcessingSharedImageJS lock still reduces duplicate temp-DB writes and navigations. -
Deep-link listener registered 2 seconds after mount. The share extension opens
timesafari://immediately. If Capacitor does not buffer the launch URL until theappUrlOpenlistener is registered (at T+2000ms), the deep-link path may be missed on cold start. The 1000ms startup check andappStateChangepaths partially compensate. -
Plugin registration vs. first
getSharedImage()call.SharedImagePluginis registered with up to 5 retries starting at T+500ms. AgetSharedImage()call before registration completes will fail. The 1000ms startup delay usually avoids this, butappStateChangecan fire earlier. -
sharedPhotoReadyflag cleared before JS reads image.AppDelegate.checkForSharedImageOnActivationclears the flag and postsSharedPhotoReady, but no JavaScript code listens for that NSNotification. The flag is therefore a redundant signal; reliance is entirely on file/metadata presence. If file write failed but flag were set, the flag would be cleared with no image available (current code sets flag only after successfulstoreImageData). -
SharedPhotoReadyNSNotification is a dead signal. Posted inAppDelegatebut not bridged to Capacitor/JS. All actual consumption happens through JS-initiatedgetSharedImage()calls. -
Concurrent share while app is open. A second share overwrites the App Group file and metadata. If the first share has already been read into temp DB but the user has not yet reached
SharedPhotoView, the second share can replace native data; navigation refresh via_refreshquery param handles re-navigation but temp DB overwrite instoreSharedImageInTempDBcan clobber an in-flight first image. -
Extension
completeRequesttiming.completeRequestruns in theprocessSharedImagecompletion handler afterstoreImageData, flag set, andopenMainApp— so the file should exist before the extension exits. However,loadItemis asynchronous; if the extension process is terminated aggressively by iOS aftercompleteRequest, this is generally safe because all writes complete in the callback before completion. -
Stale comment implies polling that does not exist.
handleDeepLinkcomments reference internal polling incheckAndStoreNativeSharedImage, 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). -
hasSharedImage()unused. A non-destructive pre-check is available natively but JS always callsgetSharedImage()directly. Since Phase 1C both methods are non-destructive on the native layer.
Share ID Tracking
Implemented: 2026-06-23 (Phase 1A)
Phase 1A adds a unique share identifier to the iOS share flow for observability and future reliability work. Existing retrieval and deletion behavior is unchanged.
Identifier
| Property | Value |
|---|---|
| UserDefaults key | sharedPhotoShareId |
| Format | UUID().uuidString (e.g. A1B2C3D4-E5F6-7890-ABCD-EF1234567890) |
| Generated in | ShareViewController.processSharedImage when the first image attachment is found |
| Persisted in | ShareViewController.storeImageData alongside sharedPhotoFilePath and sharedPhotoFileName |
Logging
All log lines use the prefix [ShareTarget] and include shareId=<id>:
| Event | File | Method | When |
|---|---|---|---|
| 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 (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 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 onlybase64andfileNameto JavaScriptsharedPhotoShareIdis not deleted on retrieve (cleanup deferred to a later phase)hasSharedImage(),isSharedPhotoReady(), and JS consumption paths are unchanged- Android code is unchanged
Write Inventory Addition
| File | Method | Key Written |
|---|---|---|
ShareViewController.swift |
storeImageData(_:fileName:shareId:) |
sharedPhotoShareId |
Read Inventory Addition
| File | Method | Key Read |
|---|---|---|
SharedImageUtility.swift |
getSharedImageData() |
sharedPhotoShareId (logging only) |
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] shareId=<id> retrieved
[ShareTarget] shareId=<id> left intact after retrieval
Phase 1B Scope (Intentionally Unchanged)
getSharedImageData()still returns onlybase64and originalfileNameto JavaScript- 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.tsandSharedPhotoView.vueunchanged - Android code unchanged
Configuration References
| Resource | Value |
|---|---|
| App Group ID | group.app.timesafari.share |
| URL scheme | timesafari:// |
| Extension bundle ID | app.timesafari.TimeSafariShareExtension |
| Main app bundle ID | app.timesafari |
| Capacitor plugin name | SharedImage |
| Temp DB key | shared-photo-base64 (SHARED_PHOTO_BASE64_KEY) |
| Route | /shared-photo |