diff --git a/doc/share-target-ios-audit.md b/doc/share-target-ios-audit.md new file mode 100644 index 00000000..1cb88afa --- /dev/null +++ b/doc/share-target-ios-audit.md @@ -0,0 +1,280 @@ +# 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 | Relative filename of image file in container | +| `sharedPhotoFileName` | UserDefaults (suite) | Share Extension | Display/original filename | +| `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() │ +│ (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 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 + +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. + +--- + +## 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` |