Files
crowd-funder-for-time-pwa/doc/share-target-ios-audit.md
Jose Olarte III ddbd07f315 feat(ios): use UUID-based filenames for shared images (Phase 1B)
Store shared images as <shareId>.<ext> in the App Group container while
keeping the original filename in metadata, preventing on-disk collisions
without changing retrieval, deletion, or JS consumer behavior.
2026-06-23 19:37:11 +08:00

22 KiB
Raw Blame History

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()            │
│  (reads file, deletes native data)  │
└─────────────────────────────────────┘
        │
        ▼
┌─────────────────────────────────────┐
│  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() 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 commentcheckAndStoreNativeSharedImage() 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 applicationDidBecomeActivecheckForSharedImageOnActivation Reads sharedPhotoReady flag; clears flag; posts SharedPhotoReady NSNotification (no JS listener)
2 JS Module load + 1000ms delay main.capacitor.ts setTimeoutcheckForSharedImageAndNavigate 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 appUrlOpenhandleDeepLinkcheckAndStoreNativeSharedImage 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 didFinishLaunchingtryRegister Registers SharedImagePlugin (not a share check, but required for JS reads)

Note: Detection points 14 can all fire for a single share event. Only the JS paths (#24) 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

  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.

  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.

  3. 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.

  4. 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).

  5. SharedPhotoReady NSNotification is a dead signal. Posted in AppDelegate but not bridged to Capacitor/JS. All actual consumption happens through JS-initiated getSharedImage() calls.

  6. 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.

  7. 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.

  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.


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:

[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

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:

[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>

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