# 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 (`.`) | | `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}` (`.`) | --- ## 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 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. 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 `getSharedImage()` 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=`: | 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 only `base64` and `fileName` to JavaScript - `sharedPhotoShareId` is 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: `.`, 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 `.` | | `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= originalFilename= storedFilename=. [ShareTarget] metadata stored shareId= originalFilename= storedFilename=. [ShareTarget] shareId= retrieved [ShareTarget] shareId= left intact after retrieval ``` ### Phase 1B Scope (Intentionally Unchanged) - `getSharedImageData()` still returns only `base64` and original `fileName` to 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= retrieved [ShareTarget] shareId= 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 --- ## Deterministic Startup Plugin Readiness **Implemented:** 2026-06-26 (Phase 2A) Phase 2A removes the iOS startup race between native `SharedImage` plugin registration and the first JS shared-image check. All changes are confined to `src/main.capacitor.ts`; no Swift code changed. ### Race condition removed Previously the initial iOS shared-image check ran on a fixed timer: ```ts const checkDelays = ... : [1000]; // iOS checkDelays.forEach((delay) => setTimeout(() => checkForSharedImageAndNavigate(), delay)); ``` The native `SharedImagePlugin` is registered asynchronously by `AppDelegate.didFinishLaunchingWithOptions` with up to 5 retries starting at T+500ms (`AppDelegate.swift:21–40`). The fixed 1000ms JS delay only *assumed* registration had completed by then. When registration was slow (or the WebView booted unusually fast), the first `checkForSharedImageAndNavigate()` could call `SharedImage.getSharedImage()` before the native plugin existed, the call would throw, and the cold-start share could be missed until a later `appStateChange`. This corresponds to race condition #3 in *Potential Race Conditions* above. ### How plugin readiness is now determined The fixed 1000ms iOS delay is replaced with an explicit, deterministic wait (`waitForSharedImagePluginReady()` in `main.capacitor.ts`): - The plugin is probed with a lightweight, read-only `SharedImage.hasSharedImage()` call. A successful resolution proves the native plugin instance is registered and reachable from JS. `hasSharedImage()` does not consume or mutate the pending share (non-destructive since Phase 1C), so probing is side-effect free. - If the probe throws (plugin not yet registered), it retries within a bounded budget: `STARTUP_PLUGIN_MAX_ATTEMPTS = 10` attempts spaced `STARTUP_PLUGIN_RETRY_DELAY_MS = 300` ms apart (~3s ceiling, covering the native registration window). No arbitrary sleep is used to *assume* readiness; the delay is only the inter-retry backoff while polling for actual availability. - The very first `checkForSharedImageAndNavigate()` runs only after the probe succeeds. If the budget is exhausted (should not happen in practice), the check is still attempted once as a best-effort fallback so behavior is never worse than the previous fixed-delay path, and `appStateChange` retries on the next activation. ### Temporary diagnostics The retry sequence emits `[ShareTarget]` console diagnostics, consistent with the existing TEMPORARY SHARE TARGET DIAGNOSTICS convention: ``` [ShareTarget] Startup shared-image check waiting for SharedImage plugin [ShareTarget] SharedImage plugin ready after N attempt(s) [ShareTarget] Startup shared-image check giving up after N attempt(s) ``` ### Phase 2A Scope (Intentionally Unchanged) The retry/readiness logic applies **only** to the initial startup shared-image check. The following are deliberately untouched: - `appStateChange` handling (`CapacitorApp.addListener("appStateChange", ...)`) - `appUrlOpen` handling (`handleDeepLink`, `registerDeepLinkListener`) - Router navigation to `/shared-photo` - Share processing (`checkAndStoreNativeSharedImage`, `storeSharedImageInTempDB`) - Android startup behavior (still `[500, 1500, 3000]` ms multi-delay checks) - All native Swift code, including the `AppDelegate` plugin-registration retry Because readiness is now confirmed by an actual plugin response rather than a timer, the startup check no longer depends on registration timing, while every other detection path keeps its previous semantics as redundant backstops. --- ## 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` |