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:
Jose Olarte III
2025-12-04 18:03:47 +08:00
parent eeac7fdb66
commit 84983ee10b
14 changed files with 1498 additions and 218 deletions

View File

@@ -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) {

View 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 };

View 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 };
}
}

View 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 }>;
}