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.
525 lines
28 KiB
Markdown
525 lines
28 KiB
Markdown
# 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 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=<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: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` |
|