refactor(shared-image): replace temp file approach with native Capacitor plugins
Replace the buggy temp file polling mechanism with direct native-to-JS communication via custom Capacitor plugins for iOS and Android. This eliminates race conditions, file management complexity, and polling overhead. BREAKING CHANGE: Removes backward compatibility with temp file approach. Android minSdkVersion upgraded from 22 to 23. Changes: - iOS: Created SharedImagePlugin (CAPBridgedPlugin) and SharedImageUtility - Uses App Group UserDefaults for data sharing between extension and app - Implements getSharedImage() and hasSharedImage() methods - Removes temp file writing/reading logic from AppDelegate - Android: Created SharedImagePlugin with @CapacitorPlugin annotation - Uses SharedPreferences for data storage instead of temp files - Implements same interface as iOS plugin - Removes temp file handling from MainActivity - TypeScript: Added plugin definitions and registration - Created SharedImagePlugin interface and web fallback - Updated main.capacitor.ts to use plugin instead of Filesystem API - Removed pollForFileExistence() and related file I/O code - Android: Upgraded minSdkVersion from 22 to 23 - Required for SharedPreferences improvements and better API support Benefits: - Eliminates race conditions from file polling - Removes file cleanup complexity - Direct native-to-JS communication (no file I/O) - Better performance (no polling overhead) - More reliable data transfer between share extension and app - Cleaner architecture with proper plugin abstraction
This commit is contained in:
@@ -31,7 +31,6 @@
|
||||
import { initializeApp } from "./main.common";
|
||||
import { App as CapacitorApp } from "@capacitor/app";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem";
|
||||
import router from "./router";
|
||||
import { handleApiError } from "./services/api";
|
||||
import { AxiosError } from "axios";
|
||||
@@ -39,6 +38,7 @@ import { DeepLinkHandler } from "./services/deepLinks";
|
||||
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 "./utils/safeAreaInset";
|
||||
|
||||
logger.log("[Capacitor] 🚀 Starting initialization");
|
||||
@@ -58,45 +58,6 @@ 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 directory - Directory to check (default: Directory.Documents)
|
||||
* @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,
|
||||
directory: Directory = Directory.Documents,
|
||||
maxRetries: number = 5,
|
||||
initialDelay: number = 100,
|
||||
): Promise<boolean> {
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
await Filesystem.stat({
|
||||
path: filePath,
|
||||
directory: directory,
|
||||
});
|
||||
// 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
|
||||
@@ -160,8 +121,9 @@ async function storeSharedImageInTempDB(
|
||||
* @throws {Error} If URL format is invalid
|
||||
*/
|
||||
/**
|
||||
* Check for native shared image from iOS App Group UserDefaults
|
||||
* and store in temp database before routing to shared-photo view
|
||||
* 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;
|
||||
@@ -189,78 +151,28 @@ async function checkAndStoreNativeSharedImage(): Promise<{
|
||||
|
||||
const platform = Capacitor.getPlatform();
|
||||
logger.debug(
|
||||
`[Main] Checking for ${platform} shared image from native layer`,
|
||||
`[Main] Checking for ${platform} shared image via SharedImage plugin`,
|
||||
);
|
||||
|
||||
// TEMP FILE APPROACH:
|
||||
// Native layer (AppDelegate on iOS, MainActivity on Android) writes the shared image data
|
||||
// to a temp file, and we read it here using Capacitor's Filesystem plugin.
|
||||
//
|
||||
// This approach is simple, reliable, and works consistently across both platforms.
|
||||
// The temp file is deleted immediately after reading to prevent re-processing.
|
||||
//
|
||||
// FUTURE IMPROVEMENT: Consider implementing Capacitor plugins for iOS and Android
|
||||
// to provide a more direct native-to-JS bridge. This would eliminate the need for
|
||||
// file I/O and polling, providing lower latency and a more "native" integration.
|
||||
// However, the current temp file approach is production-ready and performs well.
|
||||
const tempFilePath = "timesafari_shared_photo.json";
|
||||
// Use SharedImage plugin to get shared image data directly from native layer
|
||||
// No file I/O or polling needed - direct native-to-JS communication
|
||||
const result = await SharedImage.getSharedImage();
|
||||
|
||||
// Use platform-specific directory:
|
||||
// - iOS: Directory.Documents (AppDelegate writes to Documents directory)
|
||||
// - Android: Directory.Data (MainActivity writes to getFilesDir() which maps to Data)
|
||||
const fileDirectory =
|
||||
platform === "android" ? Directory.Data : Directory.Documents;
|
||||
if (result && result.base64) {
|
||||
const fileName = result.fileName || "shared-image.jpg";
|
||||
|
||||
// Check if file exists first (more reliable than hardcoded timeout)
|
||||
const fileExists = await pollForFileExistence(tempFilePath, fileDirectory);
|
||||
if (fileExists) {
|
||||
try {
|
||||
const fileContent = await Filesystem.readFile({
|
||||
path: tempFilePath,
|
||||
directory: fileDirectory,
|
||||
encoding: Encoding.UTF8,
|
||||
});
|
||||
// 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);
|
||||
|
||||
if (fileContent.data) {
|
||||
const sharedData = JSON.parse(fileContent.data as string);
|
||||
const base64 = sharedData.base64;
|
||||
const fileName = sharedData.fileName || "shared-image.jpg";
|
||||
|
||||
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: fileDirectory,
|
||||
});
|
||||
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 };
|
||||
}
|
||||
}
|
||||
} catch (fileError: unknown) {
|
||||
logger.error(
|
||||
"[Main] Temp file exists but couldn't be read:",
|
||||
fileError,
|
||||
);
|
||||
isProcessingSharedImage = false;
|
||||
return { success: false };
|
||||
}
|
||||
} else {
|
||||
logger.debug("[Main] Temp file not found after polling");
|
||||
isProcessingSharedImage = false;
|
||||
return { success: true, fileName };
|
||||
}
|
||||
|
||||
// No shared image found
|
||||
logger.debug("[Main] No shared image found via plugin");
|
||||
isProcessingSharedImage = false;
|
||||
return { success: false };
|
||||
} catch (error) {
|
||||
|
||||
15
src/plugins/SharedImagePlugin.ts
Normal file
15
src/plugins/SharedImagePlugin.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* SharedImage Capacitor Plugin
|
||||
* Provides access to shared image data from native Share Extension/Intent
|
||||
*/
|
||||
|
||||
import { registerPlugin } from "@capacitor/core";
|
||||
import type { SharedImagePlugin } from "./definitions";
|
||||
|
||||
const SharedImage = registerPlugin<SharedImagePlugin>("SharedImage", {
|
||||
web: () =>
|
||||
import("./SharedImagePlugin.web").then((m) => new m.SharedImagePluginWeb()),
|
||||
});
|
||||
|
||||
export * from "./definitions";
|
||||
export { SharedImage };
|
||||
21
src/plugins/SharedImagePlugin.web.ts
Normal file
21
src/plugins/SharedImagePlugin.web.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Web implementation of SharedImagePlugin
|
||||
* Returns null/false for web platform (no native sharing support)
|
||||
*/
|
||||
|
||||
import { WebPlugin } from "@capacitor/core";
|
||||
import type { SharedImagePlugin, SharedImageResult } from "./definitions";
|
||||
|
||||
export class SharedImagePluginWeb
|
||||
extends WebPlugin
|
||||
implements SharedImagePlugin
|
||||
{
|
||||
async getSharedImage(): Promise<SharedImageResult | null> {
|
||||
// Web platform doesn't support native sharing
|
||||
return null;
|
||||
}
|
||||
|
||||
async hasSharedImage(): Promise<{ hasImage: boolean }> {
|
||||
return { hasImage: false };
|
||||
}
|
||||
}
|
||||
23
src/plugins/definitions.ts
Normal file
23
src/plugins/definitions.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Type definitions for SharedImage plugin
|
||||
*/
|
||||
|
||||
export interface SharedImageResult {
|
||||
base64: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export interface SharedImagePlugin {
|
||||
/**
|
||||
* Get shared image data from native layer
|
||||
* Returns base64 string and fileName, or null if no image exists
|
||||
* Clears the data after reading to prevent re-reading
|
||||
*/
|
||||
getSharedImage(): Promise<SharedImageResult | null>;
|
||||
|
||||
/**
|
||||
* Check if shared image exists without reading it
|
||||
* Useful for quick checks before calling getSharedImage()
|
||||
*/
|
||||
hasSharedImage(): Promise<{ hasImage: boolean }>;
|
||||
}
|
||||
Reference in New Issue
Block a user