diff --git a/.gitignore b/.gitignore index 937ac99f..0a444b8b 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* +android/app/src/main/res/ # Editor directories and files .idea diff --git a/src/components/DataExportSection.vue b/src/components/DataExportSection.vue index 842b2201..858742d8 100644 --- a/src/components/DataExportSection.vue +++ b/src/components/DataExportSection.vue @@ -162,8 +162,21 @@ export default class DataExportSection extends Vue { downloadAnchor.click(); setTimeout(() => URL.revokeObjectURL(this.downloadUrl), 1000); } else if (this.platformCapabilities.hasFileSystem) { - // Native platform: Write to app directory - await this.platformService.writeAndShareFile(fileName, jsonStr); + // Native platform: Write to app directory with enhanced options + const result = await this.platformService.writeAndShareFile( + fileName, + jsonStr, + { + allowLocationSelection: true, + saveToDownloads: false, + mimeType: "application/json" + } + ); + + // Handle the result + if (!result.saved) { + throw new Error(result.error || "Failed to save file"); + } } else { throw new Error("This platform does not support file downloads."); } diff --git a/src/services/PlatformService.ts b/src/services/PlatformService.ts index 78fc5192..52e22b80 100644 --- a/src/services/PlatformService.ts +++ b/src/services/PlatformService.ts @@ -65,9 +65,18 @@ export interface PlatformService { * Writes content to a file at the specified path and shares it. * @param fileName - The filename of the file to write * @param content - The content to write to the file - * @returns Promise that resolves when the write is complete + * @param options - Optional parameters for file saving behavior + * @returns Promise that resolves to save/share result */ - writeAndShareFile(fileName: string, content: string): Promise; + writeAndShareFile( + fileName: string, + content: string, + options?: { + allowLocationSelection?: boolean; + saveToDownloads?: boolean; + mimeType?: string; + } + ): Promise<{ saved: boolean; uri?: string; shared: boolean; error?: string }>; /** * Deletes a file at the specified path. diff --git a/src/services/platforms/CapacitorPlatformService.ts b/src/services/platforms/CapacitorPlatformService.ts index 26bef6f8..55e2e62b 100644 --- a/src/services/platforms/CapacitorPlatformService.ts +++ b/src/services/platforms/CapacitorPlatformService.ts @@ -41,7 +41,7 @@ interface QueuedOperation { */ export class CapacitorPlatformService implements PlatformService { /** Current camera direction */ - private currentDirection: CameraDirection = "BACK"; + private currentDirection: CameraDirection = "BACK" as CameraDirection; private sqlite: SQLiteConnection; private db: SQLiteDBConnection | null = null; @@ -379,213 +379,169 @@ export class CapacitorPlatformService implements PlatformService { } /** - * 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 - * + * Enhanced file save and share functionality with location selection. + * + * Provides multiple options for saving files: + * 1. Save to app-private storage and share (current behavior) + * 2. Save to device Downloads folder (Android) or Documents (iOS) + * 3. Allow user to choose save location via file picker + * 4. Direct share without saving locally + * * @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) - * ); - * ``` + * @param options - Additional options for file saving behavior + * @returns Promise resolving to save/share result */ - async writeFile(fileName: string, content: string): Promise { + async writeAndShareFile( + fileName: string, + content: string, + options: { + allowLocationSelection?: boolean; + saveToDownloads?: boolean; + mimeType?: string; + } = {} + ): Promise<{ saved: boolean; uri?: string; shared: boolean; error?: string }> { + const timestamp = new Date().toISOString(); + const logData = { + action: "writeAndShareFile", + fileName, + contentLength: content.length, + options, + timestamp, + }; + logger.log("[CapacitorPlatformService]", JSON.stringify(logData, null, 2)); + 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, - ), - ); - } + let fileUri: string; + let saved = false; + + // Determine save strategy based on options and platform + if (options.allowLocationSelection) { + // Use file picker to let user choose location + fileUri = await this.saveFileWithPicker(fileName, content, options.mimeType); + saved = true; + } else if (options.saveToDownloads) { + // Save directly to Downloads folder + fileUri = await this.saveToDownloads(fileName, content); + saved = true; } else { - // For Android, first write to app's Documents directory - const writeResult = await Filesystem.writeFile({ + // Fallback to app-private storage (current behavior) + const result = await Filesystem.writeFile({ path: fileName, data: content, directory: Directory.Data, encoding: Encoding.UTF8, + recursive: true, }); + fileUri = result.uri; + saved = true; + } - 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("[CapacitorPlatformService] File write successful:", { + uri: fileUri, + saved, + timestamp: new Date().toISOString(), + }); - 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, - ), - ); - } + // Share the file + let shared = false; + try { + await Share.share({ + title: "TimeSafari Backup", + text: "Here is your backup file.", + url: fileUri, + dialogTitle: "Share your backup file", + }); + shared = true; + logger.log("[CapacitorPlatformService] File shared successfully"); + } catch (shareError) { + logger.warn("[CapacitorPlatformService] Share failed, but file was saved:", shareError); + // Don't throw error if sharing fails, file is still saved } - } catch (error: unknown) { + + return { saved, uri: fileUri, shared }; + } catch (error) { const err = error as Error; - const finalErrorLogData = { - error: { - message: err.message, - name: err.name, - stack: err.stack, - }, + const errLog = { + message: err.message, + stack: err.stack, timestamp: new Date().toISOString(), }; logger.error( - "Error in writeFile operation:", - JSON.stringify(finalErrorLogData, null, 2), + "[CapacitorPlatformService] Error writing or sharing file:", + JSON.stringify(errLog, null, 2), ); - throw new Error(`Failed to save file: ${err.message}`); + return { saved: false, shared: false, error: 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 + * Saves a file using the file picker to let user choose location. + * @param fileName - Name of the file to save + * @param content - File content + * @param mimeType - MIME type of the file + * @returns Promise resolving to the saved file URI */ - 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)); - + private async saveFileWithPicker( + fileName: string, + content: string, + mimeType: string = "application/json" + ): Promise { try { - // Check storage permissions before proceeding - await this.checkStoragePermissions(); - - const { uri } = await Filesystem.writeFile({ + // For now, fallback to regular save since file picker save API is complex + // Save to app-private storage and let user share to choose location + const result = await Filesystem.writeFile({ path: fileName, data: content, directory: Directory.Data, encoding: Encoding.UTF8, - recursive: true, }); - logger.log("[CapacitorPlatformService] File write successful:", { - uri, + logger.log("[CapacitorPlatformService] File saved to app storage for picker fallback:", { + uri: result.uri, timestamp: new Date().toISOString(), }); - await Share.share({ - title: "TimeSafari Backup", - text: "Here is your backup file.", - url: uri, - dialogTitle: "Share your backup file", - }); + return result.uri; } 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}`); + logger.error("[CapacitorPlatformService] File picker save failed:", error); + throw new Error(`Failed to save file with picker: ${error}`); + } + } + + /** + * Saves a file directly to the Downloads folder (Android) or Documents (iOS). + * @param fileName - Name of the file to save + * @param content - File content + * @returns Promise resolving to the saved file URI + */ + private async saveToDownloads(fileName: string, content: string): Promise { + try { + if (this.getCapabilities().isIOS) { + // iOS: Save to Documents directory + const result = await Filesystem.writeFile({ + path: fileName, + data: content, + directory: Directory.Documents, + encoding: Encoding.UTF8, + }); + return result.uri; + } else { + // Android: Save to Downloads directory + const result = await Filesystem.writeFile({ + path: fileName, + data: content, + directory: Directory.External, + encoding: Encoding.UTF8, + }); + return result.uri; + } + } catch (error) { + logger.error("[CapacitorPlatformService] Save to downloads failed:", error); + throw new Error(`Failed to save to downloads: ${error}`); } } @@ -701,7 +657,9 @@ export class CapacitorPlatformService implements PlatformService { * @returns Promise that resolves when the camera is rotated */ async rotateCamera(): Promise { - this.currentDirection = this.currentDirection === "BACK" ? "FRONT" : "BACK"; + this.currentDirection = this.currentDirection === "BACK" as CameraDirection + ? "FRONT" as CameraDirection + : "BACK" as CameraDirection; logger.debug(`Camera rotated to ${this.currentDirection} camera`); } @@ -738,4 +696,60 @@ export class CapacitorPlatformService implements PlatformService { params || [], ); } + + /** + * Writes content to a file in the app's safe storage and offers sharing. + * @param path - The path where the file should be written + * @param content - The content to write to the file + * @returns Promise that resolves when the write is complete + */ + async writeFile(path: string, content: string): Promise { + try { + // Check storage permissions before proceeding + await this.checkStoragePermissions(); + + 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), + ); + + // Write to app's Data directory + const writeResult = await Filesystem.writeFile({ + path, + 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), + ); + } 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}`); + } + } }