import { ImageResult, PlatformService, PlatformCapabilities, } from "../PlatformService"; import { Filesystem, Directory, Encoding } from "@capacitor/filesystem"; import { Camera, CameraResultType, CameraSource } from "@capacitor/camera"; import { FilePicker } from "@capawesome/capacitor-file-picker"; import { logger } from "../../utils/logger"; /** * 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 */ export class CapacitorPlatformService implements PlatformService { /** * 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 user-selected directory. * Opens a directory picker for the user to choose where to save. * @param path - Suggested filename * @param content - Content to write to the file * @throws Error if write operation fails */ async writeFile(path: string, content: string): Promise { try { const logData = { targetPath: path, contentLength: content.length, platform: this.getCapabilities().isIOS ? "iOS" : "Android", timestamp: new Date().toISOString(), }; logger.log( "Starting writeFile operation", JSON.stringify(logData, null, 2), ); // Check and request storage permissions if needed await this.checkStoragePermissions(); // Let user pick save location first const result = await FilePicker.pickDirectory(); const pickerLogData = { path: result.path, platform: this.getCapabilities().isIOS ? "iOS" : "Android", timestamp: new Date().toISOString(), }; logger.log("FilePicker result:", JSON.stringify(pickerLogData, null, 2)); // Handle paths based on platform let cleanPath = result.path; if (this.getCapabilities().isIOS) { const iosLogData = { originalPath: cleanPath, timestamp: new Date().toISOString(), }; logger.log("Processing iOS path", JSON.stringify(iosLogData, null, 2)); cleanPath = result.path; } else { const androidLogData = { originalPath: cleanPath, timestamp: new Date().toISOString(), }; logger.log( "Processing Android path", JSON.stringify(androidLogData, null, 2), ); // For Android, use the content URI directly if (cleanPath.startsWith("content://")) { const uriLogData = { uri: cleanPath, filename: path, timestamp: new Date().toISOString(), }; logger.log( "Using content URI for Android:", JSON.stringify(uriLogData, null, 2), ); // Extract the document ID from the content URI const docIdMatch = cleanPath.match(/tree\/(.*?)(?:\/|$)/); if (docIdMatch) { const docId = docIdMatch[1]; const docIdLogData = { docId, timestamp: new Date().toISOString(), }; logger.log( "Extracted document ID:", JSON.stringify(docIdLogData, null, 2), ); // Use the document ID as the path cleanPath = docId; } } } const finalPath = cleanPath; const finalPathLogData = { fullPath: finalPath, filename: path, timestamp: new Date().toISOString(), }; logger.log( "Final path details:", JSON.stringify(finalPathLogData, null, 2), ); // Write to the selected directory const writeLogData = { path: finalPath, contentLength: content.length, timestamp: new Date().toISOString(), }; logger.log( "Attempting file write:", JSON.stringify(writeLogData, null, 2), ); try { if (this.getCapabilities().isIOS) { await Filesystem.writeFile({ path: finalPath, data: content, directory: Directory.Documents, recursive: true, encoding: Encoding.UTF8, }); } else { // For Android, use the content URI directly const androidPath = `Download/${path}`; const directoryLogData = { path: androidPath, directory: Directory.ExternalStorage, timestamp: new Date().toISOString(), }; logger.log( "Android path configuration:", JSON.stringify(directoryLogData, null, 2), ); await Filesystem.writeFile({ path: androidPath, data: content, directory: Directory.ExternalStorage, recursive: true, encoding: Encoding.UTF8, }); } const writeSuccessLogData = { path: finalPath, timestamp: new Date().toISOString(), }; logger.log( "File write successful", JSON.stringify(writeSuccessLogData, null, 2), ); } catch (writeError: unknown) { const error = writeError as Error; const writeErrorLogData = { error: { message: error.message, name: error.name, stack: error.stack, }, path: finalPath, contentLength: content.length, timestamp: new Date().toISOString(), }; logger.error( "File write failed:", JSON.stringify(writeErrorLogData, null, 2), ); throw new Error(`Failed to write file: ${error.message}`); } } 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 to selected location: ${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(); } }