From 2bbf2303120273be4432004acff43a976e2e8e25 Mon Sep 17 00:00:00 2001 From: Trent Larson Date: Sun, 20 Sep 2026 21:35:52 -0600 Subject: [PATCH] adjust the URL for sharing a photo with the app (since it was going to the root "/" home route for sharing and that caused conflicts when we actually want to deep-link to the home page) --- .../java/app/timesafari/MainActivity.java | 40 ++- ios/App/App/AppDelegate.swift | 2 +- .../ShareViewController.swift | 5 +- src/interfaces/deepLinks.ts | 2 + src/main.capacitor.ts | 248 +++++------------- src/services/deepLinks.ts | 3 + src/services/sharedImageHandoff.ts | 58 ++++ src/test/services/deepLinks.test.ts | 50 ++++ src/test/services/sharedImageHandoff.test.ts | 64 +++++ 9 files changed, 277 insertions(+), 195 deletions(-) create mode 100644 src/services/sharedImageHandoff.ts create mode 100644 src/test/services/deepLinks.test.ts create mode 100644 src/test/services/sharedImageHandoff.test.ts diff --git a/android/app/src/main/java/app/timesafari/MainActivity.java b/android/app/src/main/java/app/timesafari/MainActivity.java index fed494f2..1209ff8b 100644 --- a/android/app/src/main/java/app/timesafari/MainActivity.java +++ b/android/app/src/main/java/app/timesafari/MainActivity.java @@ -30,6 +30,7 @@ public class MainActivity extends BridgeActivity { private static final String KEY_BASE64 = "shared_image_base64"; private static final String KEY_FILE_NAME = "shared_image_file_name"; private static final String KEY_READY = "shared_image_ready"; + private static final Uri SHARED_PHOTO_DEEP_LINK = Uri.parse("timesafari://shared-photo"); @Override public void onCreate(Bundle savedInstanceState) { @@ -121,7 +122,9 @@ public class MainActivity extends BridgeActivity { } if (imageUri != null) { String fileName = intent.getStringExtra(Intent.EXTRA_TEXT); - processSharedImage(imageUri, fileName); + if (processSharedImage(imageUri, fileName)) { + notifySharedPhotoDeepLink(); + } handled = true; } } @@ -138,7 +141,9 @@ public class MainActivity extends BridgeActivity { imageUris = uris; } if (imageUris != null && !imageUris.isEmpty()) { - processSharedImage(imageUris.get(0), null); + if (processSharedImage(imageUris.get(0), null)) { + notifySharedPhotoDeepLink(); + } handled = true; } } @@ -157,10 +162,12 @@ public class MainActivity extends BridgeActivity { } /** - * Process a shared image: read it, convert to base64, and write to temp file + * Process a shared image: read it, convert to base64, and write it to SharedPreferences * Uses try-with-resources to ensure proper stream cleanup and prevent network issues + * + * @return true when the image is available to the SharedImage plugin */ - private void processSharedImage(Uri imageUri, String fileName) { + private boolean processSharedImage(Uri imageUri, String fileName) { // Extract filename from URI or use default (do this before opening streams) String actualFileName = fileName; if (actualFileName == null || actualFileName.isEmpty()) { @@ -183,7 +190,7 @@ public class MainActivity extends BridgeActivity { if (inputStream == null) { Log.e(TAG, "Failed to open input stream for shared image"); - return; + return false; } // Read image bytes @@ -199,21 +206,36 @@ public class MainActivity extends BridgeActivity { String base64String = Base64.encodeToString(imageBytes, Base64.NO_WRAP); // Store in SharedPreferences for plugin to read - storeSharedImageInPreferences(base64String, actualFileName); + if (!storeSharedImageInPreferences(base64String, actualFileName)) { + return false; + } Log.d(TAG, "Successfully processed shared image: " + actualFileName); + return true; } catch (IOException e) { Log.e(TAG, "Error processing shared image", e); + return false; } catch (Exception e) { Log.e(TAG, "Unexpected error processing shared image", e); + return false; } } + + /** + * Deliver an App-plugin URL event after the shared image is ready. The App plugin retains + * URL-open events until JavaScript registers its listener, covering both cold and warm starts. + */ + private void notifySharedPhotoDeepLink() { + Intent deepLinkIntent = new Intent(Intent.ACTION_VIEW, SHARED_PHOTO_DEEP_LINK); + getBridge().onNewIntent(deepLinkIntent); + Log.d(TAG, "Delivered shared-photo deep link to Capacitor"); + } /** * Store shared image data in SharedPreferences for plugin to read * Plugin will read and clear the data when called */ - private void storeSharedImageInPreferences(String base64, String fileName) { + private boolean storeSharedImageInPreferences(String base64, String fileName) { try { SharedPreferences prefs = getSharedPreferences(SHARED_PREFS_NAME, MODE_PRIVATE); SharedPreferences.Editor editor = prefs.edit(); @@ -223,9 +245,11 @@ public class MainActivity extends BridgeActivity { editor.apply(); Log.d(TAG, "Stored shared image data in SharedPreferences"); + return true; } catch (Exception e) { Log.e(TAG, "Error storing shared image in SharedPreferences", e); + return false; } } -} \ No newline at end of file +} diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift index c60baaed..816a3a09 100644 --- a/ios/App/App/AppDelegate.swift +++ b/ios/App/App/AppDelegate.swift @@ -159,7 +159,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { // Called when the app was launched with a url. Feel free to add additional processing here, // but if you want the App API to support tracking app url opens, make sure to keep this call - // Note: Share Extension opens app with timesafari:// (empty path), which is handled by JavaScript + // Note: Share Extension opens app with timesafari://shared-photo, which is handled by JavaScript // via the appUrlOpen listener in main.capacitor.ts return ApplicationDelegateProxy.shared.application(app, open: url, options: options) } diff --git a/ios/App/TimeSafariShareExtension/ShareViewController.swift b/ios/App/TimeSafariShareExtension/ShareViewController.swift index cf967dff..20fd9d29 100644 --- a/ios/App/TimeSafariShareExtension/ShareViewController.swift +++ b/ios/App/TimeSafariShareExtension/ShareViewController.swift @@ -46,7 +46,7 @@ class ShareViewController: UIViewController { if success { // Set flag that shared photo is ready self.setSharedPhotoReadyFlag() - // Open the main app (using minimal URL - app will detect shared data on activation) + // Open the main app at its shared-photo deep link. self.openMainApp() } @@ -186,8 +186,7 @@ class ShareViewController: UIViewController { } private func openMainApp() { - // Open the main app with minimal URL - app will detect shared data on activation - guard let url = URL(string: "timesafari://") else { + guard let url = URL(string: "timesafari://shared-photo") else { return } diff --git a/src/interfaces/deepLinks.ts b/src/interfaces/deepLinks.ts index 203a1604..97d54bf7 100644 --- a/src/interfaces/deepLinks.ts +++ b/src/interfaces/deepLinks.ts @@ -74,6 +74,8 @@ export const deepLinkPathSchemas = { project: z.object({ id: z.string(), }), + // Internal handoff used by the native image-share targets. + "shared-photo": z.object({}), "user-profile": z.object({ id: z.string(), }), diff --git a/src/main.capacitor.ts b/src/main.capacitor.ts index 8e46aee8..d25347ce 100644 --- a/src/main.capacitor.ts +++ b/src/main.capacitor.ts @@ -39,6 +39,7 @@ import { logger, safeStringify } from "./utils/logger"; import { PlatformServiceFactory } from "./services/PlatformServiceFactory"; import { SHARED_PHOTO_BASE64_KEY } from "./libs/util"; import { SharedImage } from "./plugins/SharedImagePlugin"; +import { createSharedImageHandoff } from "./services/sharedImageHandoff"; import "./utils/safeAreaInset"; // Load Daily Notification plugin at startup so native performRecovery() runs at launch (rollover recovery) @@ -89,9 +90,6 @@ window.addEventListener("unhandledrejection", (event) => { const deepLinkHandler = new DeepLinkHandler(router); -// Lock to prevent duplicate processing of shared images -let isProcessingSharedImage = false; - /** * Stores shared image data in temp database * Handles clearing old data, converting base64 to data URL, and storing @@ -138,87 +136,80 @@ async function storeSharedImageInTempDB( logger.info(`[Main] Stored shared image: ${fileName || "unknown"}`); } -/** - * Handles deep link routing for the application - * Processes URLs in the format timesafari:/// - * Maps incoming deep links to corresponding router paths with parameters - * - * @param {Object} data - Deep link data object - * @param {string} data.url - The full deep link URL to process - * @returns {Promise} - * - * @example - * // Handles URLs like: - * // timesafari://claim/01JMAAFZRNSRTQ0EBSD70A8E1H - * // timesafari://project/abc123 - * - * @throws {Error} If URL format is invalid - */ -/** - * Check for native shared image using SharedImage plugin - * Reads from native layer (App Group UserDefaults on iOS, SharedPreferences on Android) - * and stores in temp database before routing to shared-photo view - */ -async function checkAndStoreNativeSharedImage(): Promise<{ - success: boolean; - fileName?: string; -}> { - // Prevent duplicate processing - if (isProcessingSharedImage) { - logger.debug( - "[Main] ⏸️ Shared image processing already in progress, skipping", - ); - return { success: false }; +async function navigateToSharedPhoto(fileName: string): Promise { + await router.isReady(); + + if (router.currentRoute.value.path === "/shared-photo") { + await router.replace({ + path: "/shared-photo", + query: { fileName, _refresh: Date.now().toString() }, + }); + } else { + await router.push({ + path: "/shared-photo", + query: { fileName }, + }); } - isProcessingSharedImage = true; + logger.info(`[Main] ✅ Navigated to /shared-photo?fileName=${fileName}`); +} - try { - if ( - !Capacitor.isNativePlatform() || - (Capacitor.getPlatform() !== "ios" && - Capacitor.getPlatform() !== "android") - ) { - isProcessingSharedImage = false; - return { success: false }; - } - - // Use SharedImage plugin to get shared image data directly from native layer - // No file I/O or polling needed - direct native-to-JS communication - let result; +const sharedImageHandoff = createSharedImageHandoff({ + getSharedImage: async () => { try { - result = await SharedImage.getSharedImage(); + return await SharedImage.getSharedImage(); } catch (error) { logger.error("[Main] Error calling SharedImage.getSharedImage():", { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); - isProcessingSharedImage = false; - return { success: false }; + return null; } + }, + storeSharedImage: async (base64, fileName) => { + logger.info("[Main] Native shared image found, storing in temp DB"); + await storeSharedImageInTempDB(base64, fileName); + }, + navigateToSharedPhoto, +}); - // Check if we have valid image data (base64 must be non-null and non-empty) - if (result && result.base64 && result.base64.trim().length > 0) { - const fileName = result.fileName || "shared-image.jpg"; +/** + * Consume a pending native shared image and route to the share screen. The + * in-flight handoff is shared by URL, launch, and activation callbacks because + * the native plugin removes an image after it is read. + */ +async function checkForSharedImageAndNavigate(): Promise { + if ( + !Capacitor.isNativePlatform() || + (Capacitor.getPlatform() !== "ios" && Capacitor.getPlatform() !== "android") + ) { + return false; + } - // Store in temp database using extracted method - logger.info( - "[Main] Native shared image found (via plugin), storing in temp DB", - ); - await storeSharedImageInTempDB(result.base64, fileName); - - isProcessingSharedImage = false; - return { success: true, fileName }; + try { + const navigated = await sharedImageHandoff.process(); + if (!navigated) { + logger.debug("[Main] ℹ️ No native shared image found"); } - - // No shared image found - logger.debug("[Main] No shared image found via plugin"); - isProcessingSharedImage = false; - return { success: false }; + return navigated; } catch (error) { - logger.error("[Main] Error checking for native shared image:", error); - isProcessingSharedImage = false; - return { success: false }; + logger.error("[Main] ❌ Error processing native shared image:", error); + return false; + } +} + +function isSharedPhotoDeepLink(url: string): boolean { + try { + const parsed = new URL(url); + return ( + parsed.protocol === "timesafari:" && + parsed.hostname === "shared-photo" && + (parsed.pathname === "" || parsed.pathname === "/") && + !parsed.search && + !parsed.hash + ); + } catch { + return false; } } @@ -227,67 +218,10 @@ const handleDeepLink = async (data: { url: string }) => { logger.debug(`[Main] 🌐 Deeplink received from Capacitor: ${url}`); try { - // Handle empty path URLs from share extension (timesafari://) - // These are used to open the app, and we should check for shared images - const isEmptyPathUrl = url === "timesafari://" || url === "timesafari:///"; - - if ( - isEmptyPathUrl && - Capacitor.isNativePlatform() && - Capacitor.getPlatform() === "ios" - ) { - logger.debug( - "[Main] 📸 Empty path URL from share extension, checking for native shared image", - ); - - // Try to get shared image from App Group and store in temp database - // AppDelegate writes the file when the deep link is received, so we may need to retry - // The checkAndStoreNativeSharedImage function now uses polling internally, so we just call it once - try { - const imageResult = await checkAndStoreNativeSharedImage(); - - if (imageResult.success) { - logger.info( - "[Main] ✅ Native shared image found, navigating to shared-photo", - ); - - // Wait for router to be ready - await router.isReady(); - - // Navigate directly to shared-photo route - // Use replace if already on shared-photo to force refresh, otherwise push - const fileName = imageResult.fileName || "shared-image.jpg"; - const isAlreadyOnSharedPhoto = - router.currentRoute.value.path === "/shared-photo"; - - if (isAlreadyOnSharedPhoto) { - // Force refresh by replacing the route - await router.replace({ - path: "/shared-photo", - query: { fileName, _refresh: Date.now().toString() }, // Add timestamp to force update - }); - } else { - await router.push({ - path: "/shared-photo", - query: { fileName }, - }); - } - - logger.info( - `[Main] ✅ Navigated to /shared-photo?fileName=${fileName}`, - ); - return; // Exit early, don't process as deep link - } else { - logger.debug( - "[Main] ℹ️ No native shared image found, ignoring empty path URL", - ); - return; // Exit early, don't process empty path as deep link - } - } catch (error) { - logger.error("[Main] Error processing native shared image:", error); - // If check fails, don't process as deep link (empty path would fail validation anyway) - return; - } + if (isSharedPhotoDeepLink(url) && Capacitor.isNativePlatform()) { + logger.debug("[Main] 📸 Shared-photo handoff received"); + await checkForSharedImageAndNavigate(); + return; } // Wait for router to be ready @@ -377,58 +311,6 @@ const registerDeepLinkListener = async () => { } }; -/** - * Check for shared image and navigate to shared-photo route if found - * This is called when app becomes active (from share extension or app launch) - */ -async function checkForSharedImageAndNavigate() { - if ( - !Capacitor.isNativePlatform() || - (Capacitor.getPlatform() !== "ios" && Capacitor.getPlatform() !== "android") - ) { - return; - } - - try { - logger.debug("[Main] 🔍 Checking for shared image on app activation"); - const imageResult = await checkAndStoreNativeSharedImage(); - - if (imageResult.success) { - logger.info( - "[Main] ✅ Shared image found, navigating to shared-photo route", - ); - - // Wait for router to be ready - await router.isReady(); - - // Navigate to shared-photo route with fileName if available - // Use replace if already on shared-photo to force refresh, otherwise push - const fileName = imageResult.fileName || "shared-image.jpg"; - const isAlreadyOnSharedPhoto = - router.currentRoute.value.path === "/shared-photo"; - - if (isAlreadyOnSharedPhoto) { - // Force refresh by replacing the route - await router.replace({ - path: "/shared-photo", - query: { fileName, _refresh: Date.now().toString() }, // Add timestamp to force update - }); - } else { - const route = imageResult.fileName - ? `/shared-photo?fileName=${encodeURIComponent(imageResult.fileName)}` - : "/shared-photo"; - await router.push(route); - } - - logger.info(`[Main] ✅ Navigated to /shared-photo?fileName=${fileName}`); - } else { - logger.debug("[Main] ℹ️ No shared image found"); - } - } catch (error) { - logger.error("[Main] ❌ Error checking for shared image:", error); - } -} - logger.log("[Capacitor] 🚀 Mounting app"); app.mount("#app"); logger.info(`[Main] ✅ App mounted successfully`); diff --git a/src/services/deepLinks.ts b/src/services/deepLinks.ts index b7907a0e..2d141193 100644 --- a/src/services/deepLinks.ts +++ b/src/services/deepLinks.ts @@ -64,6 +64,9 @@ export const ROUTE_MAP: Record = const ROUTE_NAME_RESOLVERS: Record string> = { // An empty path is the app's front door. "": () => "home", + // The native handoff stores its image before the route is opened. The normal + // handler keeps this route valid for the web and desktop implementations. + "shared-photo": () => "shared-photo", // Both QR paths mean "show my QR code and scan theirs". Which view provides // that differs by platform: a full-screen camera view where a native scanner // is available, an ordinary page where the in-page web reader is used. diff --git a/src/services/sharedImageHandoff.ts b/src/services/sharedImageHandoff.ts new file mode 100644 index 00000000..da75a3ee --- /dev/null +++ b/src/services/sharedImageHandoff.ts @@ -0,0 +1,58 @@ +/** + * Coordinates consuming a native shared image and opening the shared-photo view. + * + * Native image storage is consumed when read. Several lifecycle events can arrive + * for one share, so callers must share one in-flight operation. + */ +export interface NativeSharedImage { + base64: string | null | undefined; + fileName: string | null | undefined; +} + +export interface SharedImageHandoffDependencies { + getSharedImage: () => Promise; + storeSharedImage: (base64: string, fileName: string) => Promise; + navigateToSharedPhoto: (fileName: string) => Promise; +} + +export interface SharedImageHandoff { + process: () => Promise; +} + +export function createSharedImageHandoff({ + getSharedImage, + storeSharedImage, + navigateToSharedPhoto, +}: SharedImageHandoffDependencies): SharedImageHandoff { + let inFlight: Promise | undefined; + + const processOnce = async (): Promise => { + const image = await getSharedImage(); + if (!image?.base64?.trim()) { + return false; + } + + const fileName = image.fileName || "shared-image.jpg"; + await storeSharedImage(image.base64, fileName); + await navigateToSharedPhoto(fileName); + return true; + }; + + return { + async process(): Promise { + if (inFlight) { + return inFlight; + } + + const operation = processOnce(); + inFlight = operation; + try { + return await operation; + } finally { + if (inFlight === operation) { + inFlight = undefined; + } + } + }, + }; +} diff --git a/src/test/services/deepLinks.test.ts b/src/test/services/deepLinks.test.ts new file mode 100644 index 00000000..d06852fd --- /dev/null +++ b/src/test/services/deepLinks.test.ts @@ -0,0 +1,50 @@ +jest.mock("../../services/QRNavigationService", () => ({ + QRNavigationService: { + getInstance: () => ({ + getQRScannerRoute: () => ({ name: "contact-qr" }), + }), + }, +})); + +jest.mock("../../utils/logger", () => ({ + logger: { + debug: jest.fn(), + error: jest.fn(), + }, +})); + +import { DeepLinkHandler } from "../../services/deepLinks"; + +function routerSpy() { + return { + replace: jest.fn().mockResolvedValue(undefined), + }; +} + +describe("DeepLinkHandler share routes", () => { + it("routes a bare URL to home", async () => { + const router = routerSpy(); + const handler = new DeepLinkHandler(router as never); + + await handler.handleDeepLink("timesafari://"); + + expect(router.replace).toHaveBeenCalledWith({ + name: "home", + params: {}, + query: {}, + }); + }); + + it("recognizes the native shared-photo handoff route", async () => { + const router = routerSpy(); + const handler = new DeepLinkHandler(router as never); + + await handler.handleDeepLink("timesafari://shared-photo"); + + expect(router.replace).toHaveBeenCalledWith({ + name: "shared-photo", + params: {}, + query: {}, + }); + }); +}); diff --git a/src/test/services/sharedImageHandoff.test.ts b/src/test/services/sharedImageHandoff.test.ts new file mode 100644 index 00000000..19cd6829 --- /dev/null +++ b/src/test/services/sharedImageHandoff.test.ts @@ -0,0 +1,64 @@ +import { createSharedImageHandoff } from "../../services/sharedImageHandoff"; + +describe("createSharedImageHandoff", () => { + it("shares one native read and navigation across concurrent callers", async () => { + let releaseRead: (image: { base64: string; fileName: string }) => void; + const getSharedImage = jest.fn( + () => + new Promise<{ base64: string; fileName: string }>((resolve) => { + releaseRead = resolve; + }), + ); + const storeSharedImage = jest.fn().mockResolvedValue(undefined); + const navigateToSharedPhoto = jest.fn().mockResolvedValue(undefined); + const handoff = createSharedImageHandoff({ + getSharedImage, + storeSharedImage, + navigateToSharedPhoto, + }); + + const first = handoff.process(); + const second = handoff.process(); + releaseRead!({ base64: "image-data", fileName: "photo.jpg" }); + + await expect(Promise.all([first, second])).resolves.toEqual([true, true]); + expect(getSharedImage).toHaveBeenCalledTimes(1); + expect(storeSharedImage).toHaveBeenCalledWith("image-data", "photo.jpg"); + expect(navigateToSharedPhoto).toHaveBeenCalledWith("photo.jpg"); + }); + + it("does not navigate when native storage has no image", async () => { + const storeSharedImage = jest.fn(); + const navigateToSharedPhoto = jest.fn(); + const handoff = createSharedImageHandoff({ + getSharedImage: async () => null, + storeSharedImage, + navigateToSharedPhoto, + }); + + await expect(handoff.process()).resolves.toBe(false); + expect(storeSharedImage).not.toHaveBeenCalled(); + expect(navigateToSharedPhoto).not.toHaveBeenCalled(); + }); + + it("allows a later share after the first operation completes", async () => { + const getSharedImage = jest + .fn() + .mockResolvedValueOnce({ base64: "first", fileName: "first.jpg" }) + .mockResolvedValueOnce({ base64: "second", fileName: "second.jpg" }); + const storeSharedImage = jest.fn().mockResolvedValue(undefined); + const navigateToSharedPhoto = jest.fn().mockResolvedValue(undefined); + const handoff = createSharedImageHandoff({ + getSharedImage, + storeSharedImage, + navigateToSharedPhoto, + }); + + await handoff.process(); + await handoff.process(); + + expect(getSharedImage).toHaveBeenCalledTimes(2); + expect(navigateToSharedPhoto).toHaveBeenNthCalledWith(1, "first.jpg"); + expect(navigateToSharedPhoto).toHaveBeenNthCalledWith(2, "second.jpg"); + }); +});