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
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() |
Reads file from App Group, returns base64 + fileName; deletes metadata and file |
|
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 |
getSharedImageData() |
sharedPhotoFilePath, sharedPhotoFileName |
SharedImageUtility.swift |
clearSharedPhotoReadyFlag() |
sharedPhotoReady |
AppDelegate.swift |
checkForSharedImageOnActivation() |
sharedPhotoReady (via clearSharedPhotoReadyFlag()) |
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 |
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 overwrite |
ShareViewController.swift |
storeImageData |
Existing file at same filename |
| 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 |
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 survive until getSharedImageData() succeeds. The sharedPhotoReady flag is cleared independently and earlier by AppDelegate.
Potential Race Conditions
-
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.
-
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.
-
Plugin registration vs. first getSharedImage() call. SharedImagePlugin is registered with up to 5 retries starting at T+500ms. A getSharedImage() call before registration completes will fail. The 1000ms startup delay usually avoids this, but appStateChange can fire earlier.
-
sharedPhotoReady flag cleared before JS reads image. AppDelegate.checkForSharedImageOnActivation clears the flag and posts SharedPhotoReady, 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 successful storeImageData).
-
SharedPhotoReady NSNotification is a dead signal. Posted in AppDelegate but not bridged to Capacitor/JS. All actual consumption happens through JS-initiated getSharedImage() 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 _refresh query param handles re-navigation but temp DB overwrite in storeSharedImageInTempDB can clobber an in-flight first image.
-
Extension completeRequest timing. completeRequest runs in the processSharedImage completion handler after storeImageData, flag set, and openMainApp — so the file should exist before the extension exits. However, loadItem is asynchronous; if the extension process is terminated aggressively by iOS after completeRequest, this is generally safe because all writes complete in the callback before completion.
-
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).
-
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.
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 (only if sharedPhotoShareId is present) |
Example log sequence for a single share:
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)
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:
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 |
| 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 |