From 981f8b77d0d7b44cda3b973b6528085234d34708 Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Tue, 30 Jun 2026 11:00:52 +0800 Subject: [PATCH] feat(ios): add markProcessed share-completion API (Phase 1D) Add a native iOS markProcessed(shareId) method to the SharedImage plugin that records an explicit completion signal in App Group UserDefaults (sharedPhotoProcessedShareId) and logs "[ShareTarget] shareId= marked processed". The API is exposed to TypeScript via the plugin definitions and a web no-op stub. Deliberately does not delete files or clear share metadata (cleanup is deferred to a later phase), is not yet called from application code, and does not touch Android. Documents the share-target lifecycle and plugin API in doc/share-target-ios-audit.md. --- doc/share-target-ios-audit.md | 86 ++++++++++++++++++++++++++++ ios/App/App/SharedImagePlugin.swift | 20 ++++++- ios/App/App/SharedImageUtility.swift | 27 +++++++++ src/plugins/SharedImagePlugin.web.ts | 7 +++ src/plugins/definitions.ts | 13 +++++ 5 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 doc/share-target-ios-audit.md diff --git a/doc/share-target-ios-audit.md b/doc/share-target-ios-audit.md new file mode 100644 index 00000000..13e9fd53 --- /dev/null +++ b/doc/share-target-ios-audit.md @@ -0,0 +1,86 @@ +# iOS Share Target Audit + +**Date:** 2026-06-30 +**Platform:** iOS only (Android unchanged) +**Status:** Phase 1D complete — explicit share completion API added (not yet called) + +## Purpose + +This document tracks the native iOS share-target lifecycle and the +Capacitor API surface exposed to the web/TypeScript layer. It is updated as +each phase lands. + +## Lifecycle Overview + +1. The Share Extension (`ShareViewController.swift`) receives an image, writes + it to the App Group container, records metadata in App Group + `UserDefaults`, assigns a `shareId` (UUID), sets the `sharedPhotoReady` + flag, and opens the main app via `timesafari://`. +2. The main app reads the shared image through the `SharedImage` Capacitor + plugin. +3. The application layer processes the image, then (in a future phase) signals + explicit completion via `markProcessed(shareId)`. + +## App Group Storage Keys + +Stored in `UserDefaults(suiteName: "group.app.trentlarson.timesafari.share")`: + +| Key | Set by | Meaning | +| --- | --- | --- | +| `sharedPhotoFileName` | Share Extension | Original display filename | +| `sharedPhotoFilePath` | Share Extension | On-disk filename in App Group container | +| `sharedPhotoShareId` | Share Extension | Current pending share identifier (UUID) | +| `sharedPhotoReady` | Share Extension | Flag indicating a share is ready | +| `sharedPhotoProcessedShareId` | Main app (`markProcessed`) | Last shareId explicitly marked processed (Phase 1D) | + +## Capacitor API (`SharedImage` plugin) + +The plugin is registered as `SharedImage` and is implemented in: + +- Native iOS: `ios/App/App/SharedImagePlugin.swift` (delegates to + `ios/App/App/SharedImageUtility.swift`) +- Web stub: `src/plugins/SharedImagePlugin.web.ts` +- TypeScript definitions: `src/plugins/definitions.ts` + +### `getSharedImage(): Promise` + +Returns the shared image as `{ base64, fileName }`, or `null` if none exists. +Read-only: native metadata and the file are left intact after retrieval +(Phase 1C). + +### `hasSharedImage(): Promise<{ hasImage: boolean }>` + +Returns whether a shared image file currently exists, without reading it. + +### `markProcessed(options: { shareId: string }): Promise<{ success: boolean }>` — Phase 1D + +Explicit completion signal from the application layer for a given share. + +- **Input:** `shareId` — the identifier reported by `getSharedImage()`. + Must be a non-empty string; otherwise the call rejects. +- **Behavior (iOS):** Records `shareId` in App Group `UserDefaults` under + `sharedPhotoProcessedShareId` and resolves with `{ success: true }`. +- **Does NOT delete files:** The stored image file and share metadata are + intentionally left intact. File cleanup is deferred to a later phase. +- **Web:** No-op; resolves with `{ success: false }`. +- **Logging:** Emits `[ShareTarget] shareId= marked processed` on success. + +> Note (Phase 1D): `markProcessed` is implemented and exposed to TypeScript +> but is **not yet called** from application code. Wiring it into the +> processing flow is a later phase. + +## Native Logging Reference + +Relevant `[ShareTarget]` log lines emitted on iOS: + +- `[ShareTarget] shareId= retrieved` +- `[ShareTarget] shareId= left intact after retrieval` +- `[ShareTarget] shareId= marked processed` (Phase 1D) + +## Out of Scope + +- Android: no changes. The Android plugin + (`android/app/src/main/java/app/timesafari/sharedimage/SharedImagePlugin.java`) + does not implement `markProcessed`. +- File deletion / cleanup of processed shares. +- Calling `markProcessed` from application code. diff --git a/ios/App/App/SharedImagePlugin.swift b/ios/App/App/SharedImagePlugin.swift index a18c4b27..932a1689 100644 --- a/ios/App/App/SharedImagePlugin.swift +++ b/ios/App/App/SharedImagePlugin.swift @@ -24,7 +24,8 @@ public class SharedImagePlugin: CAPPlugin, CAPBridgedPlugin { public var pluginMethods: [CAPPluginMethod] { return [ CAPPluginMethod(#selector(getSharedImage(_:)), returnType: .promise), - CAPPluginMethod(#selector(hasSharedImage(_:)), returnType: .promise) + CAPPluginMethod(#selector(hasSharedImage(_:)), returnType: .promise), + CAPPluginMethod(#selector(markProcessed(_:)), returnType: .promise) ] } @@ -62,5 +63,22 @@ public class SharedImagePlugin: CAPPlugin, CAPBridgedPlugin { "hasImage": hasImage ]) } + + /** + * Mark a share as processed by the application layer (Phase 1D) + * Records an explicit completion signal for the given shareId. + * Does NOT delete the stored image file - file cleanup is deferred to a later phase. + */ + @objc public func markProcessed(_ call: CAPPluginCall) { + guard let shareId = call.getString("shareId"), !shareId.isEmpty else { + call.reject("markProcessed requires a non-empty 'shareId'") + return + } + + let success = SharedImageUtility.markProcessed(shareId: shareId) + call.resolve([ + "success": success + ]) + } } diff --git a/ios/App/App/SharedImageUtility.swift b/ios/App/App/SharedImageUtility.swift index 82e27619..8edab1cc 100644 --- a/ios/App/App/SharedImageUtility.swift +++ b/ios/App/App/SharedImageUtility.swift @@ -15,6 +15,7 @@ public class SharedImageUtility { private static let sharedPhotoFilePathKey = "sharedPhotoFilePath" private static let sharedPhotoShareIdKey = "sharedPhotoShareId" private static let sharedPhotoReadyKey = "sharedPhotoReady" + private static let sharedPhotoProcessedShareIdKey = "sharedPhotoProcessedShareId" /// Get the App Group container URL for accessing shared files private static var appGroupContainerURL: URL? { @@ -78,6 +79,32 @@ public class SharedImageUtility { return ["base64": base64String, "fileName": fileName] } + /** + * Mark a share as processed by the web/application layer (Phase 1D). + * + * Records the processed shareId in the App Group UserDefaults so that + * later phases can decide whether retrieval/cleanup should be skipped. + * This is an explicit completion signal: it does NOT delete the stored + * image file or clear the share metadata. File cleanup is deferred to a + * later phase. + * + * @param shareId The share identifier reported by getSharedImage() + * @returns true if the processed marker was recorded, false otherwise + */ + @discardableResult + static func markProcessed(shareId: String) -> Bool { + guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else { + print("[ShareTarget] shareId=\(shareId) markProcessed failed: UserDefaults unavailable for app group") + return false + } + + userDefaults.set(shareId, forKey: sharedPhotoProcessedShareIdKey) + userDefaults.synchronize() + + print("[ShareTarget] shareId=\(shareId) marked processed") + return true + } + /** * Check if shared image exists without reading it * diff --git a/src/plugins/SharedImagePlugin.web.ts b/src/plugins/SharedImagePlugin.web.ts index d609be16..29ac1d1d 100644 --- a/src/plugins/SharedImagePlugin.web.ts +++ b/src/plugins/SharedImagePlugin.web.ts @@ -18,4 +18,11 @@ export class SharedImagePluginWeb async hasSharedImage(): Promise<{ hasImage: boolean }> { return { hasImage: false }; } + + async markProcessed(_options: { + shareId: string; + }): Promise<{ success: boolean }> { + // Web platform doesn't support native sharing - no-op + return { success: false }; + } } diff --git a/src/plugins/definitions.ts b/src/plugins/definitions.ts index f67a443c..31d736fe 100644 --- a/src/plugins/definitions.ts +++ b/src/plugins/definitions.ts @@ -20,4 +20,17 @@ export interface SharedImagePlugin { * Useful for quick checks before calling getSharedImage() */ hasSharedImage(): Promise<{ hasImage: boolean }>; + + /** + * Mark a share as explicitly processed by the application layer (Phase 1D, iOS only). + * + * Records a completion signal for the given shareId on the native side. + * This does NOT delete the stored image file or clear share metadata; + * file cleanup is deferred to a later phase. + * + * On web this is a no-op that resolves with success=false. + * + * @param options.shareId The share identifier reported by getSharedImage() + */ + markProcessed(options: { shareId: string }): Promise<{ success: boolean }>; }