Files
crowd-funder-for-time-pwa/doc/share-target-ios-audit.md
Jose Olarte III c1a5bae5c8 feat(ios): wait for SharedImage plugin before initial startup check (Phase 2A)
Eliminate the iOS startup race between asynchronous SharedImage plugin
registration and the first shared-image check. Previously the initial
check fired on a fixed 1000ms timer that only assumed the native plugin
(registered by AppDelegate with retries from T+500ms) was ready, so a
slow registration could make the first getSharedImage() call throw and
miss a cold-start share.

Replace the fixed delay with waitForSharedImagePluginReady(), which
probes the plugin via a read-only hasSharedImage() call and retries
within a bounded budget (10 attempts, 300ms apart) until the plugin
actually responds. The initial check runs only once readiness is
confirmed, with a best-effort fallback if the budget is exhausted.

Scope is limited to the initial startup check on iOS. appStateChange,
appUrlOpen/handleDeepLink, router navigation, share processing, Android
behavior, and all native Swift code are unchanged. Temporary
share-target diagnostics are preserved and extended with startup
readiness logging.

Document the change as a Phase 2A section in
doc/share-target-ios-audit.md.
2026-06-26 16:48:00 +08:00

525 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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 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=<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 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: `<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 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=<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
---
## 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:2140`). 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` |