From 4f9b146a66b440c085e5c282bcef108f1e3fed17 Mon Sep 17 00:00:00 2001 From: Matthew Raymer Date: Fri, 11 Apr 2025 07:13:07 +0000 Subject: [PATCH] fix: improve file sharing on Android using app-private storage - Replace direct file writing with app-private storage + share dialog - Add Share plugin for cross-platform file sharing - Update file paths configuration for Android - Fix permission issues by using Directory.Data instead of Documents - Simplify file export flow in DataExportSection --- android/app/capacitor.build.gradle | 1 + .../src/main/assets/capacitor.plugins.json | 4 + android/app/src/main/res/xml/file_paths.xml | 1 + android/capacitor.settings.gradle | 3 + package-lock.json | 10 + package.json | 1 + src/components/DataExportSection.vue | 2 +- src/services/PlatformService.ts | 8 + .../platforms/CapacitorPlatformService.ts | 286 ++++++++++-------- 9 files changed, 181 insertions(+), 135 deletions(-) diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index a196dce3..a54399fa 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -12,6 +12,7 @@ dependencies { implementation project(':capacitor-app') implementation project(':capacitor-camera') implementation project(':capacitor-filesystem') + implementation project(':capacitor-share') implementation project(':capawesome-capacitor-file-picker') } diff --git a/android/app/src/main/assets/capacitor.plugins.json b/android/app/src/main/assets/capacitor.plugins.json index d6020933..30b5ba98 100644 --- a/android/app/src/main/assets/capacitor.plugins.json +++ b/android/app/src/main/assets/capacitor.plugins.json @@ -11,6 +11,10 @@ "pkg": "@capacitor/filesystem", "classpath": "com.capacitorjs.plugins.filesystem.FilesystemPlugin" }, + { + "pkg": "@capacitor/share", + "classpath": "com.capacitorjs.plugins.share.SharePlugin" + }, { "pkg": "@capawesome/capacitor-file-picker", "classpath": "io.capawesome.capacitorjs.plugins.filepicker.FilePickerPlugin" diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml index bd0c4d80..680bb50e 100644 --- a/android/app/src/main/res/xml/file_paths.xml +++ b/android/app/src/main/res/xml/file_paths.xml @@ -2,4 +2,5 @@ + \ No newline at end of file diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index bccee664..736eac60 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -11,5 +11,8 @@ project(':capacitor-camera').projectDir = new File('../node_modules/@capacitor/c include ':capacitor-filesystem' project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android') +include ':capacitor-share' +project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android') + include ':capawesome-capacitor-file-picker' project(':capawesome-capacitor-file-picker').projectDir = new File('../node_modules/@capawesome/capacitor-file-picker/android') diff --git a/package-lock.json b/package-lock.json index cc5e7d01..dd158e30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@capacitor/core": "^6.2.0", "@capacitor/filesystem": "^6.0.0", "@capacitor/ios": "^6.2.0", + "@capacitor/share": "^6.0.3", "@capawesome/capacitor-file-picker": "^6.2.0", "@dicebear/collection": "^5.4.1", "@dicebear/core": "^5.4.1", @@ -2908,6 +2909,15 @@ "@capacitor/core": "^6.2.0" } }, + "node_modules/@capacitor/share": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@capacitor/share/-/share-6.0.3.tgz", + "integrity": "sha512-BkNM73Ix+yxQ7fkni8CrrGcp1kSl7u+YNoPLwWKQ1MuQ5Uav0d+CT8M67ie+3dc4jASmegnzlC6tkTmFcPTLeA==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^6.0.0" + } + }, "node_modules/@capawesome/capacitor-file-picker": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/@capawesome/capacitor-file-picker/-/capacitor-file-picker-6.2.0.tgz", diff --git a/package.json b/package.json index 4d7ce2ea..686caad4 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@capacitor/core": "^6.2.0", "@capacitor/filesystem": "^6.0.0", "@capacitor/ios": "^6.2.0", + "@capacitor/share": "^6.0.3", "@capawesome/capacitor-file-picker": "^6.2.0", "@dicebear/collection": "^5.4.1", "@dicebear/core": "^5.4.1", diff --git a/src/components/DataExportSection.vue b/src/components/DataExportSection.vue index 54a35e2c..647beb19 100644 --- a/src/components/DataExportSection.vue +++ b/src/components/DataExportSection.vue @@ -145,7 +145,7 @@ export default class DataExportSection extends Vue { } else if (this.platformCapabilities.hasFileSystem) { // Native platform: Write to app directory const content = await blob.text(); - await this.platformService.writeFile(fileName, content); + await this.platformService.writeAndShareFile(fileName, content); } this.$notify( diff --git a/src/services/PlatformService.ts b/src/services/PlatformService.ts index 5a2c9209..574b1a3a 100644 --- a/src/services/PlatformService.ts +++ b/src/services/PlatformService.ts @@ -57,6 +57,14 @@ export interface PlatformService { */ writeFile(path: string, content: string): Promise; + /** + * 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 + */ + writeAndShareFile(fileName: string, content: string): Promise; + /** * Deletes a file at the specified path. * @param path - The path to the file to delete diff --git a/src/services/platforms/CapacitorPlatformService.ts b/src/services/platforms/CapacitorPlatformService.ts index 68ce715d..9c655eb6 100644 --- a/src/services/platforms/CapacitorPlatformService.ts +++ b/src/services/platforms/CapacitorPlatformService.ts @@ -5,7 +5,7 @@ import { } from "../PlatformService"; import { Filesystem, Directory, Encoding } from "@capacitor/filesystem"; import { Camera, CameraResultType, CameraSource } from "@capacitor/camera"; -import { FilePicker } from "@capawesome/capacitor-file-picker"; +import { Share } from "@capacitor/share"; import { logger } from "../../utils/logger"; /** @@ -156,16 +156,37 @@ export class CapacitorPlatformService implements PlatformService { } /** - * 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 + * 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(path: string, content: string): Promise { + async writeFile(fileName: string, content: string): Promise { try { const logData = { - targetPath: path, + targetFileName: fileName, contentLength: content.length, platform: this.getCapabilities().isIOS ? "iOS" : "Android", timestamp: new Date().toISOString(), @@ -175,146 +196,97 @@ export class CapacitorPlatformService implements PlatformService { 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; + // For Android, we need to handle content URIs differently 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, + // 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( - "Processing Android path", - JSON.stringify(androidLogData, null, 2), + "File write successful", + JSON.stringify(writeSuccessLogData, 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, + // 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", }); - } 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), + "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, + ), ); - - await Filesystem.writeFile({ - path: androidPath, - data: content, - directory: Directory.ExternalStorage, - recursive: true, - encoding: Encoding.UTF8, - }); } + } 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: finalPath, + path: writeResult.uri, timestamp: new Date().toISOString(), }; logger.log( - "File write successful", + "File write successful to app storage", 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}`); + + // 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; @@ -330,9 +302,55 @@ export class CapacitorPlatformService implements PlatformService { "Error in writeFile operation:", JSON.stringify(finalErrorLogData, null, 2), ); - throw new Error( - `Failed to save file to selected location: ${err.message}`, - ); + 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}`); } }