import { Filesystem, Directory, Encoding } from "@capacitor/filesystem"; import { Camera, CameraResultType, CameraSource } from "@capacitor/camera"; import { Share } from "@capacitor/share"; import { SQLiteConnection, SQLiteDBConnection, CapacitorSQLite, capSQLiteChanges, DBSQLiteValues, } from "@capacitor-community/sqlite"; import { runMigrations } from "@/db-sql/migration"; import { QueryExecResult } from "@/interfaces/database"; import { ImageResult, PlatformService, PlatformCapabilities, } from "../PlatformService"; import { logger } from "../../utils/logger"; interface QueuedOperation { type: "run" | "query" | "getOneRow" | "getAll"; 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 implements PlatformService { 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() { 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; } // Start initialization this.initializationPromise = this._initialize(); try { await this.initializationPromise; } catch (error) { logger.error( "[CapacitorPlatformService] Initialize method failed:", error, ); this.initializationPromise = null; // Reset on failure throw error; } } private async _initialize(): Promise { if (this.initialized) { return; } try { // Create/Open database this.db = await this.sqlite.createConnection( this.dbName, false, "no-encryption", 1, false, ); await this.db.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: [], // SQLite plugin doesn't provide column names values: queryResult.values || [], }; break; } case "getOneRow": { const oneRowResult = await this.db.query( operation.sql, operation.params, ); result = oneRowResult.values?.[0]; break; } case "getAll": { const allResult = await this.db.query( operation.sql, operation.params, ); result = allResult.values || []; break; } } operation.resolve(result); } catch (error) { // make sure you don't try to log to the DB... infinite loop! // eslint-disable-next-line no-console console.error( "[CapacitorPlatformService] Error while processing SQL queue:", error, " ... for sql:", operation.sql, " ... with params:", operation.params, ); operation.reject(error); } } this.isProcessingQueue = false; } private async queueOperation( type: QueuedOperation["type"], sql: string, params: unknown[] = [], ): Promise { return new Promise((resolve, reject) => { const operation: QueuedOperation = { type, sql, params, resolve: (value: unknown) => resolve(value as R), reject, }; 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.", ); } } private async runCapacitorMigrations(): Promise { if (!this.db) { throw new Error("Database not initialized"); } const extractMigrationNames: (result: DBSQLiteValues) => Set = ( result, ) => { const names = result.values?.map((row: { name: string }) => row.name) || []; return new Set(names); }; const sqlExec: (sql: string) => Promise = this.db.execute.bind(this.db); const sqlQuery: (sql: string) => Promise = this.db.query.bind(this.db); runMigrations(sqlExec, sqlQuery, extractMigrationNames); } /** * Gets the capabilities of the Capacitor platform * @returns Platform capabilities object */ getCapabilities(): PlatformCapabilities { return { hasFileSystem: true, hasCamera: true, isMobile: true, isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent), hasFileDownload: false, needsFileHandlingInstructions: 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, }); 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" }); } /** * 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 || [], ); } }