import { ImageResult, PlatformService, PlatformCapabilities } from '../PlatformService' import { Filesystem, Directory, Encoding } from '@capacitor/filesystem' import { Camera, CameraResultType, CameraSource } from '@capacitor/camera' import { Share } from '@capacitor/share' import { logger } from '../../utils/logger' import { Clipboard } from '@capacitor/clipboard' /** * 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 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 { 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 { 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() } /** * Writes text to the system clipboard using Capacitor's Clipboard plugin. * @param text - The text to write to the clipboard * @returns Promise that resolves when the write is complete */ async writeToClipboard(text: string): Promise { try { await Clipboard.write({ string: text }) } catch (error) { logger.error('Error writing to clipboard:', error) throw new Error('Failed to write to clipboard') } } /** * Reads text from the system clipboard using Capacitor's Clipboard plugin. * @returns Promise resolving to the clipboard text */ async readFromClipboard(): Promise { try { const { value } = await Clipboard.read() return value } catch (error) { logger.error('Error reading from clipboard:', error) throw new Error('Failed to read from clipboard') } } }