From 09a230f43e3c9002489e860959e2c26f74fa56da Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Tue, 25 Nov 2025 18:56:45 +0800 Subject: [PATCH] refactor(ios): improve file polling and extract shared image storage logic Replace hardcoded timeout with reliable file existence polling and extract duplicate image storage code into reusable function. File Polling Improvements: - Replace hardcoded 300ms timeout with pollForFileExistence() function - Use Filesystem.stat() to check file existence instead of guessing timing - Implement exponential backoff (100ms, 200ms, 400ms, 800ms, 1600ms) - Maximum 5 retries with configurable parameters - More reliable: actually checks if file exists rather than fixed wait time Code Deduplication: - Extract storeSharedImageInTempDB() function to eliminate code duplication - Consolidates clearing old data, base64-to-dataURL conversion, and DB storage - Used in both temp file and plugin fallback code paths - Improves maintainability and reduces risk of inconsistencies This makes the code more robust and maintainable while ensuring the file is actually available before attempting to read it. --- src/main.capacitor.ts | 227 +++++++++++++++++++++++------------------- 1 file changed, 127 insertions(+), 100 deletions(-) diff --git a/src/main.capacitor.ts b/src/main.capacitor.ts index 8045b048d5..402c7fbbf4 100644 --- a/src/main.capacitor.ts +++ b/src/main.capacitor.ts @@ -58,6 +58,89 @@ const deepLinkHandler = new DeepLinkHandler(router); // Lock to prevent duplicate processing of shared images let isProcessingSharedImage = false; +/** + * Polls for file existence with exponential backoff + * More reliable than hardcoded timeout - checks if file actually exists + * + * @param filePath - Path to the file to check + * @param maxRetries - Maximum number of retry attempts (default: 5) + * @param initialDelay - Initial delay in milliseconds (default: 100) + * @returns Promise - true if file exists, false if max retries reached + */ +async function pollForFileExistence( + filePath: string, + maxRetries: number = 5, + initialDelay: number = 100, +): Promise { + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + await Filesystem.stat({ + path: filePath, + directory: Directory.Documents, + }); + // File exists + return true; + } catch (error) { + // File doesn't exist yet, wait and retry + if (attempt < maxRetries - 1) { + // Exponential backoff: 100ms, 200ms, 400ms, 800ms, 1600ms + const delay = initialDelay * Math.pow(2, attempt); + logger.debug( + `[Main] File not found (attempt ${attempt + 1}/${maxRetries}), waiting ${delay}ms...`, + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + } + return false; +} + +/** + * Stores shared image data in temp database + * Handles clearing old data, converting base64 to data URL, and storing + * + * @param base64 - Raw base64 string of the image + * @param fileName - Optional filename for logging + * @returns Promise + */ +async function storeSharedImageInTempDB( + base64: string, + fileName?: string, +): Promise { + const platformService = PlatformServiceFactory.getInstance(); + + // Clear old image from temp DB first to ensure we get the new one + try { + await platformService.dbExec("DELETE FROM temp WHERE id = ?", [ + SHARED_PHOTO_BASE64_KEY, + ]); + logger.debug("[Main] Cleared old shared image from temp DB"); + } catch (clearError) { + logger.debug( + "[Main] No old image to clear (or error clearing):", + clearError, + ); + } + + // Convert raw base64 to data URL format that base64ToBlob expects + // base64ToBlob expects format: "data:image/jpeg;base64,/9j/4AAQ..." + // Try to detect image type from base64 or default to jpeg + let mimeType = "image/jpeg"; // default + if (base64.startsWith("/9j/") || base64.startsWith("iVBORw0KGgo")) { + // JPEG or PNG + mimeType = base64.startsWith("/9j/") ? "image/jpeg" : "image/png"; + } + const dataUrl = `data:${mimeType};base64,${base64}`; + + // Use INSERT OR REPLACE to handle existing records + await platformService.dbExec( + "INSERT OR REPLACE INTO temp (id, blobB64) VALUES (?, ?)", + [SHARED_PHOTO_BASE64_KEY, dataUrl], + ); + + logger.info(`[Main] Stored shared image: ${fileName || "unknown"}`); +} + /** * Handles deep link routing for the application * Processes URLs in the format timesafari:/// @@ -112,75 +195,54 @@ async function checkAndStoreNativeSharedImage(): Promise<{ // WORKAROUND: Since the plugin isn't being auto-discovered, use a temp file bridge // AppDelegate writes the shared image data to a temp file, and we read it here - try { - const tempFilePath = "timesafari_shared_photo.json"; - const fileContent = await Filesystem.readFile({ - path: tempFilePath, - directory: Directory.Documents, - encoding: Encoding.UTF8, - }); + const tempFilePath = "timesafari_shared_photo.json"; - if (fileContent.data) { - const sharedData = JSON.parse(fileContent.data as string); - const base64 = sharedData.base64; - const fileName = sharedData.fileName || "shared-image.jpg"; + // Check if file exists first (more reliable than hardcoded timeout) + const fileExists = await pollForFileExistence(tempFilePath); + if (fileExists) { + try { + const fileContent = await Filesystem.readFile({ + path: tempFilePath, + directory: Directory.Documents, + encoding: Encoding.UTF8, + }); - if (base64) { - // Store in temp database using dbExec directly - logger.info( - "[Main] Native shared image found (via temp file), storing in temp DB", - ); - const platformService = PlatformServiceFactory.getInstance(); + if (fileContent.data) { + const sharedData = JSON.parse(fileContent.data as string); + const base64 = sharedData.base64; + const fileName = sharedData.fileName || "shared-image.jpg"; - // Clear old image from temp DB first to ensure we get the new one - try { - await platformService.dbExec("DELETE FROM temp WHERE id = ?", [ - SHARED_PHOTO_BASE64_KEY, - ]); - logger.debug("[Main] Cleared old shared image from temp DB"); - } catch (clearError) { - logger.debug( - "[Main] No old image to clear (or error clearing):", - clearError, + if (base64) { + // Store in temp database using extracted method + logger.info( + "[Main] Native shared image found (via temp file), storing in temp DB", ); + await storeSharedImageInTempDB(base64, fileName); + + // Delete the temp file immediately after reading to prevent re-reading + try { + await Filesystem.deleteFile({ + path: tempFilePath, + directory: Directory.Documents, + }); + logger.debug("[Main] Deleted temp file after reading"); + } catch (deleteError) { + logger.error("[Main] Failed to delete temp file:", deleteError); + } + + isProcessingSharedImage = false; + return { success: true, fileName }; } - - // Convert raw base64 to data URL format that base64ToBlob expects - // base64ToBlob expects format: "data:image/jpeg;base64,/9j/4AAQ..." - // Try to detect image type from base64 or default to jpeg - let mimeType = "image/jpeg"; // default - if (base64.startsWith("/9j/") || base64.startsWith("iVBORw0KGgo")) { - // JPEG or PNG - mimeType = base64.startsWith("/9j/") ? "image/jpeg" : "image/png"; - } - const dataUrl = `data:${mimeType};base64,${base64}`; - - // Use INSERT OR REPLACE to handle existing records - await platformService.dbExec( - "INSERT OR REPLACE INTO temp (id, blobB64) VALUES (?, ?)", - [SHARED_PHOTO_BASE64_KEY, dataUrl], - ); - - // Delete the temp file immediately after reading to prevent re-reading - try { - await Filesystem.deleteFile({ - path: tempFilePath, - directory: Directory.Documents, - }); - logger.debug("[Main] Deleted temp file after reading"); - } catch (deleteError) { - logger.error("[Main] Failed to delete temp file:", deleteError); - } - - logger.info(`[Main] Stored shared image: ${fileName}`); - isProcessingSharedImage = false; - return { success: true, fileName }; } + } catch (fileError: unknown) { + // File exists but can't be read - log and continue to plugin method + logger.debug( + "[Main] Temp file exists but couldn't be read, trying plugin method", + ); } - } catch (fileError: unknown) { - // File doesn't exist or can't be read - that's okay, try plugin method + } else { logger.debug( - "[Main] Temp file not found or unreadable (this is normal if plugin works)", + "[Main] Temp file not found after polling (this is normal if plugin works)", ); } @@ -258,36 +320,9 @@ async function checkAndStoreNativeSharedImage(): Promise<{ logger.info("[Main] Native shared image found, storing in temp DB"); - // Store in temp database (similar to web flow) - const platformService = PlatformServiceFactory.getInstance(); + // Store in temp database using extracted method + await storeSharedImageInTempDB(base64, fileName); - // Clear old image from temp DB first to ensure we get the new one - try { - await platformService.dbExec("DELETE FROM temp WHERE id = ?", [ - SHARED_PHOTO_BASE64_KEY, - ]); - logger.debug("[Main] Cleared old shared image from temp DB"); - } catch (clearError) { - logger.debug( - "[Main] No old image to clear (or error clearing):", - clearError, - ); - } - - // Convert raw base64 to data URL format that base64ToBlob expects - let mimeType = "image/jpeg"; // default - if (base64.startsWith("/9j/") || base64.startsWith("iVBORw0KGgo")) { - mimeType = base64.startsWith("/9j/") ? "image/jpeg" : "image/png"; - } - const dataUrl = `data:${mimeType};base64,${base64}`; - - // Use INSERT OR REPLACE to handle existing records - await platformService.dbExec( - "INSERT OR REPLACE INTO temp (id, blobB64) VALUES (?, ?)", - [SHARED_PHOTO_BASE64_KEY, dataUrl], - ); - - logger.info(`[Main] Stored shared image: ${fileName || "unknown"}`); isProcessingSharedImage = false; return { success: true, fileName: fileName || "shared-image.jpg" }; } catch (error) { @@ -317,17 +352,9 @@ const handleDeepLink = async (data: { url: string }) => { // 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 { - let imageResult = await checkAndStoreNativeSharedImage(); - - // If not found immediately, wait a bit and retry (AppDelegate may still be writing the file) - if (!imageResult.success) { - logger.debug( - "[Main] ⏳ Image not found immediately, waiting for AppDelegate to write file...", - ); - await new Promise((resolve) => setTimeout(resolve, 300)); // Wait 300ms - imageResult = await checkAndStoreNativeSharedImage(); - } + const imageResult = await checkAndStoreNativeSharedImage(); if (imageResult.success) { logger.info(