import { Filesystem, Directory, Encoding } from "@capacitor/filesystem"; import { Camera, CameraResultType, CameraSource, CameraDirection, } from "@capacitor/camera"; import { Capacitor } from "@capacitor/core"; import { Share } from "@capacitor/share"; import { SQLiteConnection, SQLiteDBConnection, CapacitorSQLite, DBSQLiteValues, } from "@capacitor-community/sqlite"; import { DailyNotification } from "@timesafari/daily-notification-plugin"; import { runMigrations } from "@/db-sql/migration"; import { QueryExecResult } from "@/interfaces/database"; import { ImageResult, PlatformService, PlatformCapabilities, NotificationStatus, PermissionStatus, PermissionResult, ScheduleOptions, NativeFetcherConfig, } from "../PlatformService"; import { logger } from "../../utils/logger"; import { BaseDatabaseService } from "./BaseDatabaseService"; interface QueuedOperation { type: "run" | "query" | "rawQuery"; sql: string; params: unknown[]; resolve: (value: unknown) => void; reject: (reason: unknown) => void; } /** * Platform service implementation for Capacitor (mobile) platform. * Provides native mobile functionality through Capacitor plugins for: * - File system operations * - Camera and image picker * - Platform-specific features * - SQLite database operations */ export class CapacitorPlatformService extends BaseDatabaseService implements PlatformService { /** Current camera direction */ private currentDirection: CameraDirection = CameraDirection.Rear; private sqlite: SQLiteConnection; private db: SQLiteDBConnection | null = null; private dbName = "timesafari.sqlite"; private initialized = false; private initializationPromise: Promise | null = null; private operationQueue: Array = []; private isProcessingQueue: boolean = false; constructor() { super(); this.sqlite = new SQLiteConnection(CapacitorSQLite); } private async initializeDatabase(): Promise { // If already initialized, return immediately if (this.initialized) { return; } // If initialization is in progress, wait for it if (this.initializationPromise) { return this.initializationPromise; } try { // Start initialization this.initializationPromise = this._initialize(); await this.initializationPromise; } catch (error) { logger.error( "[CapacitorPlatformService] Initialize database method failed:", error, ); this.initializationPromise = null; // Reset on failure throw error; } } private async _initialize(): Promise { if (this.initialized) { return; } try { // Try to create/Open database connection try { this.db = await this.sqlite.createConnection( this.dbName, false, "no-encryption", 1, false, ); } catch (createError: unknown) { // If connection already exists, try to retrieve it or handle gracefully const errorMessage = createError instanceof Error ? createError.message : String(createError); const errorObj = typeof createError === "object" && createError !== null ? (createError as { errorMessage?: string; message?: string }) : {}; const fullErrorMessage = errorObj.errorMessage || errorObj.message || errorMessage; if (fullErrorMessage.includes("already exists")) { logger.debug( "[CapacitorPlatformService] Connection already exists on native side, attempting to retrieve", ); // Check if connection exists in JavaScript Map const isConnResult = await this.sqlite.isConnection( this.dbName, false, ); if (isConnResult.result) { // Connection exists in Map, retrieve it this.db = await this.sqlite.retrieveConnection(this.dbName, false); logger.debug( "[CapacitorPlatformService] Successfully retrieved existing connection from Map", ); } else { // Connection exists on native side but not in JavaScript Map // This can happen when the app is restarted but native connections persist // Try to close the native connection first, then create a new one logger.debug( "[CapacitorPlatformService] Connection exists natively but not in Map, closing and recreating", ); try { await this.sqlite.closeConnection(this.dbName, false); } catch (closeError) { // Ignore close errors - connection might not be properly tracked logger.debug( "[CapacitorPlatformService] Error closing connection (may be expected):", closeError, ); } // Now try to create the connection again this.db = await this.sqlite.createConnection( this.dbName, false, "no-encryption", 1, false, ); logger.debug( "[CapacitorPlatformService] Successfully created connection after cleanup", ); } } else { // Re-throw if it's a different error throw createError; } } // Open the connection if it's not already open try { await this.db.open(); } catch (openError: unknown) { const openErrorMessage = openError instanceof Error ? openError.message : String(openError); // If already open, that's fine - continue if (!openErrorMessage.includes("already open")) { throw openError; } logger.debug( "[CapacitorPlatformService] Database connection already open", ); } // Set journal mode to WAL for better performance // await this.db.execute("PRAGMA journal_mode=WAL;"); // Run migrations await this.runCapacitorMigrations(); this.initialized = true; logger.log( "[CapacitorPlatformService] SQLite database initialized successfully", ); // Start processing the queue after initialization this.processQueue(); } catch (error) { logger.error( "[CapacitorPlatformService] Error initializing SQLite database:", error, ); throw new Error( "[CapacitorPlatformService] Failed to initialize database", ); } } private async processQueue(): Promise { if (this.isProcessingQueue || !this.initialized || !this.db) { return; } this.isProcessingQueue = true; while (this.operationQueue.length > 0) { const operation = this.operationQueue.shift(); if (!operation) continue; try { let result: unknown; switch (operation.type) { case "run": { const runResult = await this.db.run( operation.sql, operation.params, ); result = { changes: runResult.changes?.changes || 0, lastId: runResult.changes?.lastId, }; break; } case "query": { const queryResult = await this.db.query( operation.sql, operation.params, ); result = { columns: Object.keys(queryResult.values?.[0] || {}), values: (queryResult.values || []).map((row) => Object.values(row), ), }; break; } case "rawQuery": { const queryResult = await this.db.query( operation.sql, operation.params, ); result = queryResult; break; } } operation.resolve(result); } catch (error) { logger.error( "[CapacitorPlatformService] Error while processing SQL queue:", error, ); logger.error( `[CapacitorPlatformService] Failed operation - Type: ${operation.type}, SQL: ${operation.sql}`, ); logger.error( `[CapacitorPlatformService] Failed operation - Params:`, operation.params, ); operation.reject(error); } } this.isProcessingQueue = false; } private async queueOperation( type: QueuedOperation["type"], sql: string, params: unknown[] = [], ): Promise { // Only log SQL operations in debug mode to reduce console noise logger.debug(`[CapacitorPlatformService] queueOperation - SQL: ${sql}`); // Convert parameters to SQLite-compatible types with robust serialization const convertedParams = params.map((param, index) => { if (param === null || param === undefined) { return null; } if (typeof param === "object" && param !== null) { // Special handling for Proxy objects (common cause of "An object could not be cloned") const isProxy = this.isProxyObject(param); // AGGRESSIVE: If toString contains "Proxy", treat as Proxy even if isProxyObject returns false const stringRep = String(param); const forceProxyDetection = stringRep.includes("Proxy(") || stringRep.startsWith("Proxy"); if (isProxy || forceProxyDetection) { logger.debug( `[CapacitorPlatformService] Proxy object detected at index ${index}`, ); try { // AGGRESSIVE EXTRACTION: Try multiple methods to extract actual values if (Array.isArray(param)) { // Method 1: Array.from() to extract from Proxy(Array) const actualArray = Array.from(param); return actualArray; } else { // For Proxy(Object), try to extract actual object const actualObject = Object.assign({}, param); return actualObject; } } catch (proxyError) { logger.debug( `[CapacitorPlatformService] Failed to extract from Proxy at index ${index}:`, proxyError, ); // FALLBACK: Try to extract primitive values manually if (Array.isArray(param)) { try { const fallbackArray: unknown[] = []; for (let i = 0; i < param.length; i++) { fallbackArray.push(param[i]); } return fallbackArray; } catch (fallbackError) { return `[Proxy Array - Could not extract]`; } } return `[Proxy Object - Could not extract]`; } } try { // Safely convert objects and arrays to JSON strings return JSON.stringify(param); } catch (error) { // Handle non-serializable objects logger.debug( `[CapacitorPlatformService] Failed to serialize parameter at index ${index}:`, error, ); // Fallback: Convert to string representation if (Array.isArray(param)) { return `[Array(${param.length})]`; } return `[Object ${param.constructor?.name || "Unknown"}]`; } } if (typeof param === "boolean") { // Convert boolean to integer (0 or 1) return param ? 1 : 0; } if (typeof param === "function") { // Functions can't be serialized - convert to string representation logger.debug( `[CapacitorPlatformService] Function parameter detected and converted to string at index ${index}`, ); return `[Function ${param.name || "Anonymous"}]`; } if (typeof param === "symbol") { // Symbols can't be serialized - convert to string representation logger.debug( `[CapacitorPlatformService] Symbol parameter detected and converted to string at index ${index}`, ); return param.toString(); } // Numbers, strings, bigints are supported, but ensure bigints are converted to strings if (typeof param === "bigint") { return param.toString(); } return param; }); return new Promise((resolve, reject) => { // Create completely plain objects that Vue cannot make reactive // Step 1: Deep clone the converted params to ensure they're plain objects const plainParams = JSON.parse(JSON.stringify(convertedParams)); // Step 2: Create operation object using Object.create(null) for no prototype const operation = Object.create(null) as QueuedOperation; operation.type = type; operation.sql = sql; operation.params = plainParams; operation.resolve = (value: unknown) => resolve(value as R); operation.reject = reject; // Step 3: Freeze everything to prevent modification Object.freeze(operation.params); Object.freeze(operation); this.operationQueue.push(operation); // If we're already initialized, start processing the queue if (this.initialized && this.db) { this.processQueue(); } }); } private async waitForInitialization(): Promise { // If we have an initialization promise, wait for it if (this.initializationPromise) { await this.initializationPromise; return; } // If not initialized and no promise, start initialization if (!this.initialized) { await this.initializeDatabase(); return; } // If initialized but no db, something went wrong if (!this.db) { logger.error( "[CapacitorPlatformService] Database not properly initialized after await waitForInitialization() - initialized flag is true but db is null", ); throw new Error( "[CapacitorPlatformService] The database could not be initialized. We recommend you restart or reinstall.", ); } } /** * Detect if an object is a Proxy object that cannot be serialized * Proxy objects cause "An object could not be cloned" errors in Capacitor * @param obj - Object to test * @returns true if the object appears to be a Proxy */ private isProxyObject(obj: unknown): boolean { if (typeof obj !== "object" || obj === null) { return false; } try { // Method 1: Check toString representation const objString = obj.toString(); if (objString.includes("Proxy(") || objString.startsWith("Proxy")) { logger.debug( "[CapacitorPlatformService] Proxy detected via toString:", objString, ); return true; } // Method 2: Check constructor name const constructorName = obj.constructor?.name; if (constructorName === "Proxy") { logger.debug( "[CapacitorPlatformService] Proxy detected via constructor name", ); return true; } // Method 3: Check Object.prototype.toString const objToString = Object.prototype.toString.call(obj); if (objToString.includes("Proxy")) { logger.debug( "[CapacitorPlatformService] Proxy detected via Object.prototype.toString", ); return true; } // Method 4: Vue/Reactive Proxy detection - check for __v_ properties if (typeof obj === "object" && obj !== null) { // Check for Vue reactive proxy indicators const hasVueProxy = Object.getOwnPropertyNames(obj).some( (prop) => prop.startsWith("__v_") || prop.startsWith("__r_"), ); if (hasVueProxy) { logger.debug( "[CapacitorPlatformService] Vue reactive Proxy detected", ); return true; } } // Method 5: Try JSON.stringify and check for Proxy in error or result try { const jsonString = JSON.stringify(obj); if (jsonString.includes("Proxy")) { logger.debug( "[CapacitorPlatformService] Proxy detected in JSON serialization", ); return true; } } catch (jsonError) { // If JSON.stringify fails, it might be a non-serializable Proxy const errorMessage = jsonError instanceof Error ? jsonError.message : String(jsonError); if ( errorMessage.includes("Proxy") || errorMessage.includes("circular") || errorMessage.includes("clone") ) { logger.debug( "[CapacitorPlatformService] Proxy detected via JSON serialization error", ); return true; } } return false; } catch (error) { // If we can't inspect the object, it might be a Proxy causing issues logger.warn( "[CapacitorPlatformService] Could not inspect object for Proxy detection:", error, ); return true; // Assume it's a Proxy if we can't inspect it } } /** * Execute database migrations for the Capacitor platform * * This method orchestrates the database migration process specifically for * Capacitor-based platforms (mobile and Electron). It provides the platform-specific * SQL execution functions to the migration service and handles Capacitor SQLite * plugin integration. * * ## Migration Process: * * 1. **SQL Execution Setup**: Creates platform-specific SQL execution functions * that properly handle the Capacitor SQLite plugin's API * * 2. **Parameter Handling**: Ensures proper parameter binding for prepared statements * using the correct Capacitor SQLite methods (run vs execute) * * 3. **Result Parsing**: Provides extraction functions that understand the * Capacitor SQLite result format * * 4. **Migration Execution**: Delegates to the migration service for the actual * migration logic and tracking * * 5. **Integrity Verification**: Runs post-migration integrity checks to ensure * the database is in the expected state * * ## Error Handling: * * The method includes comprehensive error handling for: * - Database connection issues * - SQL execution failures * - Migration tracking problems * - Schema validation errors * * Even if migrations fail, the integrity check still runs to assess the * current database state and provide debugging information. * * ## Logging: * * Detailed logging is provided throughout the process using emoji-tagged * console messages that appear in the Electron DevTools console. This * includes: * - SQL statement execution details * - Parameter values for debugging * - Migration success/failure status * - Database integrity check results * * @throws {Error} If database is not initialized or migrations fail critically * @private Internal method called during database initialization * * @example * ```typescript * // Called automatically during platform service initialization * await this.runCapacitorMigrations(); * ``` */ private async runCapacitorMigrations(): Promise { if (!this.db) { throw new Error("Database not initialized"); } /** * SQL execution function for Capacitor SQLite plugin * * This function handles the execution of SQL statements (INSERT, UPDATE, CREATE, etc.) * through the Capacitor SQLite plugin. It automatically chooses the appropriate * method based on whether parameters are provided. * * @param sql - SQL statement to execute * @param params - Optional parameters for prepared statements * @returns Promise resolving to execution results */ const sqlExec = async (sql: string, params?: unknown[]): Promise => { logger.debug(`🔧 [CapacitorMigration] Executing SQL:`, sql); if (params && params.length > 0) { // Use run method for parameterized queries (prepared statements) // This is essential for proper parameter binding and SQL injection prevention await this.db!.run(sql, params); } else { // For multi-statement SQL (like migrations), use executeSet method // This handles multiple statements properly if ( sql.includes(";") && sql.split(";").filter((s) => s.trim()).length > 1 ) { // Multi-statement SQL - use executeSet for proper handling const statements = sql.split(";").filter((s) => s.trim()); await this.db!.executeSet( statements.map((stmt) => ({ statement: stmt.trim(), values: [], // Empty values array for non-parameterized statements })), ); } else { // Single statement - use execute method await this.db!.execute(sql); } } }; /** * SQL query function for Capacitor SQLite plugin * * This function handles the execution of SQL queries (SELECT statements) * through the Capacitor SQLite plugin. It returns the raw result data * that can be processed by the migration service. * * @param sql - SQL query to execute * @param params - Optional parameters for prepared statements * @returns Promise resolving to query results */ const sqlQuery = async ( sql: string, params?: unknown[], ): Promise => { logger.debug(`🔍 [CapacitorMigration] Querying SQL:`, sql); const result = await this.db!.query(sql, params); return result; }; /** * Extract migration names from Capacitor SQLite query results * * This function parses the result format returned by the Capacitor SQLite * plugin and extracts migration names. It handles the specific data structure * used by the plugin, which can vary between different result formats. * * ## Result Format Handling: * * The Capacitor SQLite plugin can return results in different formats: * - Object format: `{ name: "migration_name" }` * - Array format: `["migration_name", "timestamp"]` * * This function handles both formats to ensure robust migration name extraction. * * @param result - Query result from Capacitor SQLite plugin * @returns Set of migration names found in the result */ const extractMigrationNames = (result: DBSQLiteValues): Set => { logger.debug( `🔍 [CapacitorMigration] Extracting migration names from:`, result, ); // Handle the Capacitor SQLite result format const names = result.values ?.map((row: unknown) => { // The row could be an object with 'name' property or an array where name is first element if (typeof row === "object" && row !== null && "name" in row) { return (row as { name: string }).name; } else if (Array.isArray(row) && row.length > 0) { return row[0]; } return null; }) .filter((name) => name !== null) || []; logger.debug(`📋 [CapacitorMigration] Extracted names:`, names); return new Set(names); }; try { // Execute the migration process await runMigrations(sqlExec, sqlQuery, extractMigrationNames); // After migrations, run integrity check to verify database state await this.verifyDatabaseIntegrity(); } catch (error) { logger.error(`❌ [CapacitorMigration] Migration failed:`, error); // Still try to verify what we have for debugging purposes await this.verifyDatabaseIntegrity(); throw error; } } /** * Verify database integrity and migration status * * This method performs comprehensive validation of the database structure * and migration state. It's designed to help identify issues with the * migration process and provide detailed debugging information. * * ## Validation Steps: * * 1. **Migration Records**: Checks which migrations are recorded as applied * 2. **Table Existence**: Verifies all expected core tables exist * 3. **Schema Validation**: Checks table schemas including column presence * 4. **Data Integrity**: Validates basic data counts and structure * * ## Core Tables Validated: * * - `accounts`: User identity and cryptographic keys * - `secret`: Application secrets and encryption keys * - `settings`: Configuration and user preferences * - `contacts`: Contact network and trust relationships * - `logs`: Application event logging * - `temp`: Temporary data storage * * ## Schema Checks: * * For critical tables like `contacts`, the method validates: * - Table structure using `PRAGMA table_info` * - Presence of important columns (e.g., `iViewContent`) * - Column data types and constraints * * ## Error Handling: * * This method is designed to never throw errors - it captures and logs * all validation issues for debugging purposes. This ensures that even * if integrity checks fail, they don't prevent the application from starting. * * ## Logging Output: * * The method produces detailed console output with emoji tags: * - `✅` for successful validations * - `❌` for validation failures * - `📊` for data summaries * - `🔍` for investigation steps * * @private Internal method called after migrations * * @example * ```typescript * // Called automatically after migration completion * await this.verifyDatabaseIntegrity(); * ``` */ private async verifyDatabaseIntegrity(): Promise { if (!this.db) { logger.error(`❌ [DB-Integrity] Database not initialized`); return; } logger.debug(`🔍 [DB-Integrity] Starting database integrity check...`); try { // Step 1: Check migrations table and applied migrations const migrationsResult = await this.db.query( "SELECT name, applied_at FROM migrations ORDER BY applied_at", ); logger.debug(`📊 [DB-Integrity] Applied migrations:`, migrationsResult); // Step 2: Verify core tables exist const coreTableNames = [ "accounts", "secret", "settings", "contacts", "logs", "temp", ]; const existingTables: string[] = []; for (const tableName of coreTableNames) { try { const tableCheck = await this.db.query( `SELECT name FROM sqlite_master WHERE type='table' AND name='${tableName}'`, ); if (tableCheck.values && tableCheck.values.length > 0) { existingTables.push(tableName); logger.debug(`✅ [DB-Integrity] Table ${tableName} exists`); } else { logger.error(`❌ [DB-Integrity] Table ${tableName} missing`); } } catch (error) { logger.error( `❌ [DB-Integrity] Error checking table ${tableName}:`, error, ); } } // Step 3: Check contacts table schema (including iViewContent column) if (existingTables.includes("contacts")) { try { const contactsSchema = await this.db.query( "PRAGMA table_info(contacts)", ); logger.debug( `📊 [DB-Integrity] Contacts table schema:`, contactsSchema, ); // Check for iViewContent column specifically const hasIViewContent = contactsSchema.values?.some( (col: unknown) => (typeof col === "object" && col !== null && "name" in col && (col as { name: string }).name === "iViewContent") || (Array.isArray(col) && col[1] === "iViewContent"), ); if (hasIViewContent) { logger.debug( `✅ [DB-Integrity] iViewContent column exists in contacts table`, ); } else { logger.error( `❌ [DB-Integrity] iViewContent column missing from contacts table`, ); } } catch (error) { logger.error( `❌ [DB-Integrity] Error checking contacts schema:`, error, ); } } // Step 4: Check for basic data integrity try { const accountCount = await this.db.query( "SELECT COUNT(*) as count FROM accounts", ); const settingsCount = await this.db.query( "SELECT COUNT(*) as count FROM settings", ); const contactsCount = await this.db.query( "SELECT COUNT(*) as count FROM contacts", ); logger.debug( `📊 [DB-Integrity] Data counts - Accounts: ${JSON.stringify(accountCount)}, Settings: ${JSON.stringify(settingsCount)}, Contacts: ${JSON.stringify(contactsCount)}`, ); } catch (error) { logger.error(`❌ [DB-Integrity] Error checking data counts:`, error); } logger.log(`✅ [DB-Integrity] Database integrity check completed`); } catch (error) { logger.error(`❌ [DB-Integrity] Database integrity check failed:`, error); } } /** * Gets the capabilities of the Capacitor platform * @returns Platform capabilities object */ getCapabilities(): PlatformCapabilities { const platform = Capacitor.getPlatform(); return { hasFileSystem: true, hasCamera: true, isMobile: true, // Capacitor is always mobile isIOS: platform === "ios", hasFileDownload: false, // Mobile platforms need sharing needsFileHandlingInstructions: true, // Mobile needs instructions isNativeApp: true, }; } /** * Checks and requests storage permissions if needed * @returns Promise that resolves when permissions are granted * @throws Error if permissions are denied */ private async checkStoragePermissions(): Promise { try { const logData = { platform: this.getCapabilities().isIOS ? "iOS" : "Android", timestamp: new Date().toISOString(), }; logger.log( "Checking storage permissions", JSON.stringify(logData, null, 2), ); if (this.getCapabilities().isIOS) { // iOS uses different permission model return; } // Try to access a test directory to check permissions try { await Filesystem.stat({ path: "/storage/emulated/0/Download", directory: Directory.Documents, }); logger.log( "Storage permissions already granted", JSON.stringify({ timestamp: new Date().toISOString() }, null, 2), ); return; } catch (error: unknown) { const err = error as Error; const errorLogData = { error: { message: err.message, name: err.name, stack: err.stack, }, timestamp: new Date().toISOString(), }; // "File does not exist" is expected and not a permission error if (err.message === "File does not exist") { logger.log( "Directory does not exist (expected), proceeding with write", JSON.stringify(errorLogData, null, 2), ); return; } // Check for actual permission errors if ( err.message.includes("permission") || err.message.includes("access") ) { logger.log( "Permission check failed, requesting permissions", JSON.stringify(errorLogData, null, 2), ); // The Filesystem plugin will automatically request permissions when needed // We just need to try the operation again try { await Filesystem.stat({ path: "/storage/emulated/0/Download", directory: Directory.Documents, }); logger.log( "Storage permissions granted after request", JSON.stringify({ timestamp: new Date().toISOString() }, null, 2), ); return; } catch (retryError: unknown) { const retryErr = retryError as Error; throw new Error( `Failed to obtain storage permissions: ${retryErr.message}`, ); } } // For any other error, log it but don't treat as permission error logger.log( "Unexpected error during permission check", JSON.stringify(errorLogData, null, 2), ); return; } } catch (error: unknown) { const err = error as Error; const errorLogData = { error: { message: err.message, name: err.name, stack: err.stack, }, timestamp: new Date().toISOString(), }; logger.error( "Error checking/requesting permissions", JSON.stringify(errorLogData, null, 2), ); throw new Error(`Failed to obtain storage permissions: ${err.message}`); } } /** * Reads a file from the app's data directory. * @param path - Relative path to the file in the app's data directory * @returns Promise resolving to the file contents as string * @throws Error if file cannot be read or doesn't exist */ async readFile(path: string): Promise { const file = await Filesystem.readFile({ path, directory: Directory.Data, }); if (file.data instanceof Blob) { return await file.data.text(); } return file.data; } /** * Writes content to a file in the app's safe storage and offers sharing. * * Platform-specific behavior: * - Saves to app's Documents directory * - Offers sharing functionality to move file elsewhere * * The method handles: * 1. Writing to app-safe storage * 2. Sharing the file with user's preferred app * 3. Error handling and logging * * @param fileName - The name of the file to create (e.g. "backup.json") * @param content - The content to write to the file * * @throws Error if: * - File writing fails * - Sharing fails * * @example * ```typescript * // Save and share a JSON file * await platformService.writeFile( * "backup.json", * JSON.stringify(data) * ); * ``` */ async writeFile(fileName: string, content: string): Promise { try { // Check storage permissions before proceeding await this.checkStoragePermissions(); const logData = { targetFileName: fileName, contentLength: content.length, platform: this.getCapabilities().isIOS ? "iOS" : "Android", timestamp: new Date().toISOString(), }; logger.log( "Starting writeFile operation", JSON.stringify(logData, null, 2), ); // For Android, we need to handle content URIs differently if (this.getCapabilities().isIOS) { // Write to app's Documents directory for iOS const writeResult = await Filesystem.writeFile({ path: fileName, data: content, directory: Directory.Data, encoding: Encoding.UTF8, }); const writeSuccessLogData = { path: writeResult.uri, timestamp: new Date().toISOString(), }; logger.log( "File write successful", JSON.stringify(writeSuccessLogData, null, 2), ); // Offer to share the file try { await Share.share({ title: "TimeSafari Backup", text: "Here is your TimeSafari backup file.", url: writeResult.uri, dialogTitle: "Share your backup", }); logger.log( "Share dialog shown", JSON.stringify({ timestamp: new Date().toISOString() }, null, 2), ); } catch (shareError) { // Log share error but don't fail the operation logger.error( "Share dialog failed", JSON.stringify( { error: shareError, timestamp: new Date().toISOString(), }, null, 2, ), ); } } else { // For Android, first write to app's Documents directory const writeResult = await Filesystem.writeFile({ path: fileName, data: content, directory: Directory.Data, encoding: Encoding.UTF8, }); const writeSuccessLogData = { path: writeResult.uri, timestamp: new Date().toISOString(), }; logger.log( "File write successful to app storage", JSON.stringify(writeSuccessLogData, null, 2), ); // Then share the file to let user choose where to save it try { await Share.share({ title: "TimeSafari Backup", text: "Here is your TimeSafari backup file.", url: writeResult.uri, dialogTitle: "Save your backup", }); logger.log( "Share dialog shown for Android", JSON.stringify({ timestamp: new Date().toISOString() }, null, 2), ); } catch (shareError) { // Log share error but don't fail the operation logger.error( "Share dialog failed for Android", JSON.stringify( { error: shareError, timestamp: new Date().toISOString(), }, null, 2, ), ); } } } catch (error: unknown) { const err = error as Error; const finalErrorLogData = { error: { message: err.message, name: err.name, stack: err.stack, }, timestamp: new Date().toISOString(), }; logger.error( "Error in writeFile operation:", JSON.stringify(finalErrorLogData, null, 2), ); throw new Error(`Failed to save file: ${err.message}`); } } /** * Writes content to a file in the device's app-private storage. * Then shares the file using the system share dialog. * * Works on both Android and iOS without needing external storage permissions. * * @param fileName - The name of the file to create (e.g. "backup.json") * @param content - The content to write to the file */ async writeAndShareFile(fileName: string, content: string): Promise { const timestamp = new Date().toISOString(); const logData = { action: "writeAndShareFile", fileName, contentLength: content.length, timestamp, }; logger.log("[CapacitorPlatformService]", JSON.stringify(logData, null, 2)); try { // Check storage permissions before proceeding await this.checkStoragePermissions(); const { uri } = await Filesystem.writeFile({ path: fileName, data: content, directory: Directory.Data, encoding: Encoding.UTF8, recursive: true, }); logger.log("[CapacitorPlatformService] File write successful:", { uri, timestamp: new Date().toISOString(), }); await Share.share({ title: "TimeSafari Backup", text: "Here is your backup file.", url: uri, dialogTitle: "Share your backup file", }); } catch (error) { const err = error as Error; const errLog = { message: err.message, stack: err.stack, timestamp: new Date().toISOString(), }; logger.error( "[CapacitorPlatformService] Error writing or sharing file:", JSON.stringify(errLog, null, 2), ); throw new Error(`Failed to write or share file: ${err.message}`); } } /** * Deletes a file from the app's data directory. * @param path - Relative path to the file to delete * @throws Error if deletion fails or file doesn't exist */ async deleteFile(path: string): Promise { await Filesystem.deleteFile({ path, directory: Directory.Data, }); } /** * Lists files in the specified directory within app's data directory. * @param directory - Relative path to the directory to list * @returns Promise resolving to array of filenames * @throws Error if directory cannot be read or doesn't exist */ async listFiles(directory: string): Promise { const result = await Filesystem.readdir({ path: directory, directory: Directory.Data, }); return result.files.map((file) => typeof file === "string" ? file : file.name, ); } /** * Opens the device camera to take a picture. * Configures camera for high quality images with editing enabled. * @returns Promise resolving to the captured image data * @throws Error if camera access fails or user cancels */ async takePicture(): Promise { try { const image = await Camera.getPhoto({ quality: 90, allowEditing: true, resultType: CameraResultType.Base64, source: CameraSource.Camera, direction: this.currentDirection, }); const blob = await this.processImageData(image.base64String); return { blob, fileName: `photo_${Date.now()}.${image.format || "jpg"}`, }; } catch (error) { logger.error("Error taking picture with Capacitor:", error); throw new Error("Failed to take picture"); } } /** * Opens the device photo gallery to pick an existing image. * Configures picker for high quality images with editing enabled. * @returns Promise resolving to the selected image data * @throws Error if gallery access fails or user cancels */ async pickImage(): Promise { try { const image = await Camera.getPhoto({ quality: 90, allowEditing: true, resultType: CameraResultType.Base64, source: CameraSource.Photos, }); const blob = await this.processImageData(image.base64String); return { blob, fileName: `photo_${Date.now()}.${image.format || "jpg"}`, }; } catch (error) { logger.error("Error picking image with Capacitor:", error); throw new Error("Failed to pick image"); } } /** * Converts base64 image data to a Blob. * @param base64String - Base64 encoded image data * @returns Promise resolving to image Blob * @throws Error if conversion fails */ private async processImageData(base64String?: string): Promise { if (!base64String) { throw new Error("No image data received"); } // Convert base64 to blob const byteCharacters = atob(base64String); const byteArrays = []; for (let offset = 0; offset < byteCharacters.length; offset += 512) { const slice = byteCharacters.slice(offset, offset + 512); const byteNumbers = new Array(slice.length); for (let i = 0; i < slice.length; i++) { byteNumbers[i] = slice.charCodeAt(i); } const byteArray = new Uint8Array(byteNumbers); byteArrays.push(byteArray); } return new Blob(byteArrays, { type: "image/jpeg" }); } /** * Rotates the camera between front and back cameras. * @returns Promise that resolves when the camera is rotated */ async rotateCamera(): Promise { this.currentDirection = this.currentDirection === CameraDirection.Rear ? CameraDirection.Front : CameraDirection.Rear; logger.debug(`Camera rotated to ${this.currentDirection} camera`); } /** * Handles deep link URLs for the application. * Note: Capacitor handles deep links automatically. * @param _url - The deep link URL (unused) */ async handleDeepLink(_url: string): Promise { // Capacitor handles deep links automatically // This is just a placeholder for the interface return Promise.resolve(); } /** * @see PlatformService.dbQuery */ async dbQuery(sql: string, params?: unknown[]): Promise { await this.waitForInitialization(); return this.queueOperation("query", sql, params || []); } /** * @see PlatformService.dbExec */ async dbExec( sql: string, params?: unknown[], ): Promise<{ changes: number; lastId?: number }> { await this.waitForInitialization(); return this.queueOperation<{ changes: number; lastId?: number }>( "run", sql, params || [], ); } /** * @see PlatformService.dbGetOneRow */ async dbGetOneRow( sql: string, params?: unknown[], ): Promise { await this.waitForInitialization(); const result = await this.queueOperation( "query", sql, params || [], ); // Return the first row from the result, or undefined if no results if (result && result.values && result.values.length > 0) { return result.values[0]; } return undefined; } /** * @see PlatformService.dbRawQuery */ async dbRawQuery(sql: string, params?: unknown[]): Promise { await this.waitForInitialization(); return this.queueOperation("rawQuery", sql, params || []); } /** * Checks if running on Capacitor platform. * @returns true, as this is the Capacitor implementation */ isCapacitor(): boolean { return true; } /** * Checks if running on Electron platform. * @returns false, as this is Capacitor, not Electron */ isElectron(): boolean { return false; } /** * Checks if running on web platform. * @returns false, as this is not web */ isWeb(): boolean { return false; } // --- PWA/Web-only methods (no-op for Capacitor) --- public registerServiceWorker(): void {} // Daily notification operations /** * Get the status of scheduled daily notifications * @see PlatformService.getDailyNotificationStatus */ async getDailyNotificationStatus(): Promise { try { logger.debug( "[CapacitorPlatformService] Getting daily notification status...", ); const pluginStatus = await DailyNotification.getNotificationStatus(); // Get permissions separately const permissions = await DailyNotification.checkPermissions(); // Map plugin PermissionState to our PermissionStatus format const notificationsPermission = permissions.notifications; let notifications: "granted" | "denied" | "prompt"; if (notificationsPermission === "granted") { notifications = "granted"; } else if (notificationsPermission === "denied") { notifications = "denied"; } else { notifications = "prompt"; } // Handle lastNotificationTime which can be a Promise let lastTriggered: string | undefined; const lastNotificationTime = pluginStatus.lastNotificationTime; if (lastNotificationTime) { const timeValue = await Promise.resolve(lastNotificationTime); if (typeof timeValue === "number") { lastTriggered = new Date(timeValue).toISOString(); } } return { isScheduled: pluginStatus.isScheduled ?? false, scheduledTime: pluginStatus.settings?.time, lastTriggered, permissions: { notifications, exactAlarms: undefined, // Plugin doesn't expose this in status }, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error( "[CapacitorPlatformService] Failed to get notification status:", errorMessage, error, ); logger.warn( "[CapacitorPlatformService] Daily notification section will be hidden - plugin may not be installed or available", ); return null; } } /** * Check notification permissions * @see PlatformService.checkNotificationPermissions */ async checkNotificationPermissions(): Promise { try { const permissions = await DailyNotification.checkPermissions(); // Log the raw permission state for debugging logger.info( `[CapacitorPlatformService] Raw permission state from plugin:`, permissions, ); // Map plugin PermissionState to our PermissionStatus format const notificationsPermission = permissions.notifications; let notifications: "granted" | "denied" | "prompt"; // Handle all possible PermissionState values if (notificationsPermission === "granted") { notifications = "granted"; } else if ( notificationsPermission === "denied" || notificationsPermission === "ephemeral" ) { notifications = "denied"; } else { // Treat "prompt", "prompt-with-rationale", "unknown", "provisional" as "prompt" // This allows Android to show the permission dialog notifications = "prompt"; } logger.info( `[CapacitorPlatformService] Mapped permission state: ${notifications} (from ${notificationsPermission})`, ); return { notifications, exactAlarms: undefined, // Plugin doesn't expose this directly }; } catch (error) { logger.error( "[CapacitorPlatformService] Failed to check permissions:", error, ); return null; } } /** * Request notification permissions * @see PlatformService.requestNotificationPermissions */ async requestNotificationPermissions(): Promise { try { logger.info( `[CapacitorPlatformService] Requesting notification permissions...`, ); const result = await DailyNotification.requestPermissions(); logger.info( `[CapacitorPlatformService] Permission request result:`, result, ); // Map plugin PermissionState to boolean const notificationsGranted = result.notifications === "granted"; logger.info( `[CapacitorPlatformService] Mapped permission result: ${notificationsGranted} (from ${result.notifications})`, ); return { notifications: notificationsGranted, exactAlarms: undefined, // Plugin doesn't expose this directly }; } catch (error) { logger.error( "[CapacitorPlatformService] Failed to request permissions:", error, ); return null; } } /** * Schedule a daily notification * @see PlatformService.scheduleDailyNotification */ async scheduleDailyNotification(options: ScheduleOptions): Promise { try { await DailyNotification.scheduleDailyNotification({ time: options.time, title: options.title, body: options.body, sound: options.sound ?? true, priority: options.priority ?? "high", }); logger.info( `[CapacitorPlatformService] Scheduled daily notification for ${options.time}`, ); } catch (error) { logger.error( "[CapacitorPlatformService] Failed to schedule notification:", error, ); throw error; } } /** * Cancel scheduled daily notification * @see PlatformService.cancelDailyNotification */ async cancelDailyNotification(): Promise { try { await DailyNotification.cancelAllNotifications(); logger.info("[CapacitorPlatformService] Cancelled daily notification"); } catch (error) { logger.error( "[CapacitorPlatformService] Failed to cancel notification:", error, ); throw error; } } /** * Configure native fetcher for background operations * * This method configures the daily notification plugin's native content fetcher * with authentication credentials for background prefetch operations. It automatically * retrieves the active DID from the database and generates a fresh JWT token with * 72-hour expiration. * * **Authentication Flow:** * 1. Retrieves active DID from `active_identity` table (single source of truth) * 2. Generates JWT token with 72-hour expiration using `accessTokenForBackground()` * 3. Configures plugin with API server URL, active DID, and JWT token * 4. Plugin stores token in its Room database for background workers * * **Token Management:** * - Tokens are valid for 72 hours (4320 minutes) * - Tokens are refreshed proactively when app comes to foreground * - If token expires while offline, plugin uses cached content * - Token refresh happens automatically via `DailyNotificationSection.refreshNativeFetcherConfig()` * * **Offline-First Design:** * - 72-hour validity supports extended offline periods * - Plugin can prefetch content when online and use cached content when offline * - No app wake-up required for token refresh (happens when app is already open) * * **Error Handling:** * - Returns `null` if active DID not found (no user logged in) * - Returns `null` if JWT generation fails * - Logs errors but doesn't throw (allows graceful degradation) * * @param config - Native fetcher configuration * @param config.apiServer - API server URL (optional, uses default if not provided) * @param config.jwt - JWT token (ignored, generated automatically) * @param config.starredPlanHandleIds - Array of starred plan handle IDs for prefetch * @returns Promise that resolves when configured, or `null` if configuration failed * * @example * ```typescript * await platformService.configureNativeFetcher({ * apiServer: "https://api.endorser.ch", * jwt: "", // Generated automatically * starredPlanHandleIds: ["plan-123", "plan-456"] * }); * ``` * * @see {@link accessTokenForBackground} For JWT token generation * @see {@link DailyNotificationSection.refreshNativeFetcherConfig} For proactive token refresh * @see PlatformService.configureNativeFetcher */ async configureNativeFetcher( config: NativeFetcherConfig, ): Promise { try { // Step 1: Get activeDid from database (single source of truth) // This ensures we're using the correct user identity for authentication const activeIdentity = await this.getActiveIdentity(); const activeDid = activeIdentity.activeDid; if (!activeDid) { logger.warn( "[CapacitorPlatformService] No activeDid found, cannot configure native fetcher", ); return null; } // Step 2: Generate JWT token for background operations // Use 72-hour expiration for offline-first prefetch operations // This allows the plugin to work offline for extended periods const { accessTokenForBackground } = await import( "../../libs/crypto/index" ); // Use 72 hours (4320 minutes) for background prefetch tokens // This is longer than passkey expiration to support offline scenarios const expirationMinutes = 72 * 60; // 72 hours const jwtToken = await accessTokenForBackground( activeDid, expirationMinutes, ); if (!jwtToken) { logger.error("[CapacitorPlatformService] Failed to generate JWT token"); return null; } // Step 3: Get API server from config or use default // This ensures the plugin knows where to fetch content from let apiServer = config.apiServer || (await import("../../constants/app")).DEFAULT_ENDORSER_API_SERVER; // Step 3.5: Convert localhost to 10.0.2.2 for Android emulators // Android emulators can't reach localhost - they need 10.0.2.2 to access the host machine const platform = Capacitor.getPlatform(); if (platform === "android" && apiServer) { // Replace localhost or 127.0.0.1 with 10.0.2.2 for Android emulator compatibility apiServer = apiServer.replace( /http:\/\/(localhost|127\.0\.0\.1)(:\d+)?/, "http://10.0.2.2$2", ); } // Step 4: Configure plugin with credentials // Plugin stores these in its Room database for background workers await DailyNotification.configureNativeFetcher({ apiBaseUrl: apiServer, activeDid, jwtToken, }); // Step 5: Update starred plans if provided // This stores the starred plan IDs in SharedPreferences for the native fetcher if ( config.starredPlanHandleIds && config.starredPlanHandleIds.length > 0 ) { await DailyNotification.updateStarredPlans({ planIds: config.starredPlanHandleIds, }); logger.info( `[CapacitorPlatformService] Updated starred plans: ${config.starredPlanHandleIds.length} plans`, ); } else { // Clear starred plans if none provided await DailyNotification.updateStarredPlans({ planIds: [], }); logger.info( "[CapacitorPlatformService] Cleared starred plans (none provided)", ); } logger.info("[CapacitorPlatformService] Configured native fetcher", { activeDid, apiServer, tokenExpirationHours: 72, tokenExpirationMinutes: expirationMinutes, starredPlansCount: config.starredPlanHandleIds?.length || 0, }); } catch (error) { logger.error( "[CapacitorPlatformService] Failed to configure native fetcher:", error, ); return null; } } /** * Update starred plans for background fetcher * @see PlatformService.updateStarredPlans */ async updateStarredPlans(plans: { planIds: string[] }): Promise { try { await DailyNotification.updateStarredPlans({ planIds: plans.planIds, }); logger.info( `[CapacitorPlatformService] Updated starred plans: ${plans.planIds.length} plans`, ); } catch (error) { logger.error( "[CapacitorPlatformService] Failed to update starred plans:", error, ); return null; } } /** * Open the app's notification settings in the system settings * @see PlatformService.openAppNotificationSettings */ async openAppNotificationSettings(): Promise { try { const platform = Capacitor.getPlatform(); if (platform === "android") { // Android: Open app details settings page // From there, users can navigate to "Notifications" section // This is more reliable than trying to open notification settings directly const packageName = "app.timesafari.app"; // Full application ID from build.gradle // Use APPLICATION_DETAILS_SETTINGS which opens the app's settings page // Users can then navigate to "Notifications" section // Try multiple URL formats to ensure compatibility const intentUrl1 = `intent:#Intent;action=android.settings.APPLICATION_DETAILS_SETTINGS;data=package:${packageName};end`; const intentUrl2 = `intent://settings/app_detail?package=${packageName}#Intent;scheme=android-app;end`; logger.info( `[CapacitorPlatformService] Opening Android app settings for ${packageName}`, ); // Log current permission state before opening settings try { const currentPerms = await this.checkNotificationPermissions(); logger.info( `[CapacitorPlatformService] Current permission state before opening settings:`, currentPerms, ); } catch (e) { logger.warn( `[CapacitorPlatformService] Could not check permissions before opening settings:`, e, ); } // Try multiple approaches to ensure it works try { // Method 1: Direct window.location.href (most reliable) window.location.href = intentUrl1; // Method 2: Fallback with window.open setTimeout(() => { try { window.open(intentUrl1, "_blank"); } catch (e) { logger.warn( "[CapacitorPlatformService] window.open fallback failed:", e, ); } }, 100); // Method 3: Alternative format setTimeout(() => { try { window.location.href = intentUrl2; } catch (e) { logger.warn( "[CapacitorPlatformService] Alternative format failed:", e, ); } }, 200); } catch (e) { logger.error( "[CapacitorPlatformService] Failed to open intent URL:", e, ); } } else if (platform === "ios") { // iOS: Use app settings URL scheme const settingsUrl = `app-settings:`; window.location.href = settingsUrl; logger.info("[CapacitorPlatformService] Opening iOS app settings"); } else { logger.warn( `[CapacitorPlatformService] Cannot open settings on platform: ${platform}`, ); return null; } } catch (error) { logger.error( "[CapacitorPlatformService] Failed to open app notification settings:", error, ); return null; } } // Database utility methods - inherited from BaseDatabaseService // generateInsertStatement, updateDefaultSettings, updateActiveDid, // getActiveIdentity, insertNewDidIntoSettings, updateDidSpecificSettings, // retrieveSettingsForActiveAccount are all inherited from BaseDatabaseService }