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.
This commit is contained in:
Jose Olarte III
2025-11-25 18:56:45 +08:00
parent eff4126043
commit 09a230f43e

View File

@@ -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<boolean> - true if file exists, false if max retries reached
*/
async function pollForFileExistence(
filePath: string,
maxRetries: number = 5,
initialDelay: number = 100,
): Promise<boolean> {
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<void>
*/
async function storeSharedImageInTempDB(
base64: string,
fileName?: string,
): Promise<void> {
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://<route>/<param>
@@ -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(