- Add SafeArea and SharedImage plugins to capacitor.plugins.json - Create restore-local-plugins.js to persist plugins after cap sync - Integrate plugin restoration into build scripts - Improve Android share intent timing with retry logic - Clean up debug logging while keeping essential error handling - Add documentation for local plugin management Fixes issue where SharedImage plugin wasn't recognized, causing "UNIMPLEMENTED" errors. Image sharing now works correctly on Android.
458 lines
15 KiB
TypeScript
458 lines
15 KiB
TypeScript
/**
|
||
* @file Capacitor Main Entry Point
|
||
* @author Matthew Raymer
|
||
*
|
||
* This file initializes the deep linking system for the TimeSafari app.
|
||
* It sets up the connection between Capacitor's URL handling and our deep link processor.
|
||
*
|
||
* Deep Linking Flow:
|
||
* 1. Capacitor receives URL open event
|
||
* 2. Event is passed to DeepLinkHandler
|
||
* 3. URL is validated and processed
|
||
* 4. Router navigates to appropriate view
|
||
*
|
||
* Integration Points:
|
||
* - Capacitor App plugin for URL handling
|
||
* - Vue Router for navigation
|
||
* - Error handling system
|
||
* - Logging system
|
||
*
|
||
* Type Safety:
|
||
* - Uses DeepLinkHandler for type-safe parameter processing
|
||
* - Ensures type safety between Capacitor events and app routing
|
||
* - Maintains type checking through the entire deep link flow
|
||
*
|
||
* @example
|
||
* // URL open event from OS
|
||
* timesafari://claim/123?view=details
|
||
* // Processed and routed to appropriate view with type-safe parameters
|
||
*/
|
||
|
||
import { initializeApp } from "./main.common";
|
||
import { App as CapacitorApp } from "@capacitor/app";
|
||
import { Capacitor } from "@capacitor/core";
|
||
import router from "./router";
|
||
import { handleApiError } from "./services/api";
|
||
import { AxiosError } from "axios";
|
||
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");
|
||
logger.log("[Capacitor] Platform:", process.env.VITE_PLATFORM);
|
||
|
||
const app = initializeApp();
|
||
|
||
// Initialize API error handling for unhandled promise rejections
|
||
window.addEventListener("unhandledrejection", (event) => {
|
||
if (event.reason?.response) {
|
||
handleApiError(event.reason, event.reason.config?.url || "unknown");
|
||
}
|
||
});
|
||
|
||
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
|
||
*
|
||
* @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>
|
||
* 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<void>}
|
||
*
|
||
* @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 };
|
||
}
|
||
|
||
isProcessingSharedImage = true;
|
||
|
||
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;
|
||
try {
|
||
result = 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 };
|
||
}
|
||
|
||
// 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";
|
||
|
||
// 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 };
|
||
}
|
||
|
||
// No shared image found
|
||
logger.debug("[Main] No shared image found via plugin");
|
||
isProcessingSharedImage = false;
|
||
return { success: false };
|
||
} catch (error) {
|
||
logger.error("[Main] Error checking for native shared image:", error);
|
||
isProcessingSharedImage = false;
|
||
return { success: false };
|
||
}
|
||
}
|
||
|
||
const handleDeepLink = async (data: { url: string }) => {
|
||
const { url } = data;
|
||
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;
|
||
}
|
||
}
|
||
|
||
// Wait for router to be ready
|
||
logger.debug(`[Main] ⏳ Waiting for router to be ready...`);
|
||
await router.isReady();
|
||
logger.debug(`[Main] ✅ Router is ready, processing deeplink`);
|
||
|
||
// Process the deeplink
|
||
logger.debug(`[Main] 🚀 Starting deeplink processing`);
|
||
await deepLinkHandler.handleDeepLink(url);
|
||
logger.debug(`[Main] ✅ Deeplink processed successfully`);
|
||
} catch (error) {
|
||
logger.error(`[Main] ❌ Deeplink processing failed:`, {
|
||
url,
|
||
error: error instanceof Error ? error.message : String(error),
|
||
stack: error instanceof Error ? error.stack : undefined,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
// Log additional context for debugging
|
||
logger.error(`[Main] 🔍 Debug context:`, {
|
||
routerReady: router.isReady(),
|
||
currentRoute: router.currentRoute.value,
|
||
appMounted: app._instance?.isMounted,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
// Fallback to original error handling
|
||
let message: string =
|
||
error instanceof Error ? error.message : safeStringify(error);
|
||
if (url) {
|
||
message += `\nURL: ${url}`;
|
||
}
|
||
handleApiError({ message } as AxiosError, "deep-link");
|
||
}
|
||
};
|
||
|
||
// Function to register the deeplink listener
|
||
const registerDeepLinkListener = async () => {
|
||
try {
|
||
logger.info(
|
||
`[Main] 🔗 Attempting to register deeplink handler with Capacitor`,
|
||
);
|
||
|
||
// Check if Capacitor App plugin is available
|
||
logger.debug(`[Main] 🔍 Checking Capacitor App plugin availability...`);
|
||
if (!CapacitorApp) {
|
||
throw new Error("Capacitor App plugin not available");
|
||
}
|
||
logger.info(`[Main] ✅ Capacitor App plugin is available`);
|
||
|
||
// Check available methods on CapacitorApp
|
||
logger.debug(
|
||
`[Main] 🔍 Capacitor App plugin methods:`,
|
||
Object.getOwnPropertyNames(CapacitorApp),
|
||
);
|
||
logger.debug(
|
||
`[Main] 🔍 Capacitor App plugin addListener method:`,
|
||
typeof CapacitorApp.addListener,
|
||
);
|
||
|
||
// Wait for router to be ready first
|
||
await router.isReady();
|
||
logger.debug(
|
||
`[Main] ✅ Router is ready, proceeding with listener registration`,
|
||
);
|
||
|
||
// Try to register the listener
|
||
logger.info(`[Main] 🧪 Attempting to register appUrlOpen listener...`);
|
||
const listenerHandle = await CapacitorApp.addListener(
|
||
"appUrlOpen",
|
||
handleDeepLink,
|
||
);
|
||
logger.info(
|
||
`[Main] ✅ appUrlOpen listener registered successfully with handle:`,
|
||
listenerHandle,
|
||
);
|
||
|
||
return listenerHandle;
|
||
} catch (error) {
|
||
logger.error(`[Main] ❌ Failed to register deeplink listener:`, {
|
||
error: error instanceof Error ? error.message : String(error),
|
||
stack: error instanceof Error ? error.stack : undefined,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 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`);
|
||
|
||
// Check for shared image on initial load (in case app was launched from share sheet)
|
||
// On Android, share intents are processed in MainActivity.onCreate, so we need to check
|
||
// after a delay to ensure the native code has finished processing
|
||
if (
|
||
Capacitor.isNativePlatform() &&
|
||
(Capacitor.getPlatform() === "ios" || Capacitor.getPlatform() === "android")
|
||
) {
|
||
// Use multiple checks with increasing delays to handle timing issues
|
||
// Android share intent processing happens in onCreate, which may complete after JS loads
|
||
const checkDelays =
|
||
Capacitor.getPlatform() === "android"
|
||
? [500, 1500, 3000] // Android needs more time for share intent processing
|
||
: [1000]; // iOS is faster
|
||
|
||
checkDelays.forEach((delay) => {
|
||
setTimeout(async () => {
|
||
await checkForSharedImageAndNavigate();
|
||
}, delay);
|
||
});
|
||
}
|
||
|
||
// Listen for app state changes to detect when app becomes active
|
||
if (
|
||
Capacitor.isNativePlatform() &&
|
||
(Capacitor.getPlatform() === "ios" || Capacitor.getPlatform() === "android")
|
||
) {
|
||
CapacitorApp.addListener("appStateChange", async ({ isActive }) => {
|
||
if (isActive) {
|
||
logger.debug("[Main] 📱 App became active, checking for shared image");
|
||
await checkForSharedImageAndNavigate();
|
||
}
|
||
});
|
||
}
|
||
|
||
// Register deeplink listener after app is mounted
|
||
setTimeout(async () => {
|
||
try {
|
||
logger.info(
|
||
`[Main] ⏳ Delaying listener registration to ensure Capacitor is ready...`,
|
||
);
|
||
await registerDeepLinkListener();
|
||
logger.info(`[Main] 🎉 Deep link system fully initialized!`);
|
||
} catch (error) {
|
||
logger.error(`[Main] ❌ Deep link system initialization failed:`, error);
|
||
}
|
||
}, 2000); // 2 second delay to ensure Capacitor is fully ready
|
||
|
||
// Log app initialization status
|
||
setTimeout(() => {
|
||
logger.info(`[Main] 📊 App initialization status:`, {
|
||
routerReady: router.isReady(),
|
||
currentRoute: router.currentRoute.value,
|
||
appMounted: app._instance?.isMounted,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
}, 1000);
|