- iOS: set UNUserNotificationCenter delegate and implement willPresent so notifications show in foreground and DailyNotificationDelivered is posted for rollover; implement didReceive for tap handling; re-set delegate in applicationDidBecomeActive - Android: move DailyNotificationReceiver and BootReceiver inside <application>; add NotifyReceiver; extend BootReceiver with LOCKED_BOOT_COMPLETED, MY_PACKAGE_REPLACED, directBootAware - main.capacitor: import daily-notification-plugin at startup so plugin (and recovery) load on launch - doc: add daily-notification-alignment-outline.md Fixes foreground notifications not showing and rollover recovery; Android receivers were previously declared outside <application>.
461 lines
15 KiB
TypeScript
461 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";
|
||
|
||
// Load Daily Notification plugin at startup so native performRecovery() runs at launch (rollover recovery)
|
||
import "@timesafari/daily-notification-plugin";
|
||
|
||
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);
|