Browse Source

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
pull/130/head
Matthew Raymer 1 month ago
parent
commit
4f9b146a66
  1. 1
      android/app/capacitor.build.gradle
  2. 4
      android/app/src/main/assets/capacitor.plugins.json
  3. 1
      android/app/src/main/res/xml/file_paths.xml
  4. 3
      android/capacitor.settings.gradle
  5. 10
      package-lock.json
  6. 1
      package.json
  7. 2
      src/components/DataExportSection.vue
  8. 8
      src/services/PlatformService.ts
  9. 268
      src/services/platforms/CapacitorPlatformService.ts

1
android/app/capacitor.build.gradle

@ -12,6 +12,7 @@ dependencies {
implementation project(':capacitor-app') implementation project(':capacitor-app')
implementation project(':capacitor-camera') implementation project(':capacitor-camera')
implementation project(':capacitor-filesystem') implementation project(':capacitor-filesystem')
implementation project(':capacitor-share')
implementation project(':capawesome-capacitor-file-picker') implementation project(':capawesome-capacitor-file-picker')
} }

4
android/app/src/main/assets/capacitor.plugins.json

@ -11,6 +11,10 @@
"pkg": "@capacitor/filesystem", "pkg": "@capacitor/filesystem",
"classpath": "com.capacitorjs.plugins.filesystem.FilesystemPlugin" "classpath": "com.capacitorjs.plugins.filesystem.FilesystemPlugin"
}, },
{
"pkg": "@capacitor/share",
"classpath": "com.capacitorjs.plugins.share.SharePlugin"
},
{ {
"pkg": "@capawesome/capacitor-file-picker", "pkg": "@capawesome/capacitor-file-picker",
"classpath": "io.capawesome.capacitorjs.plugins.filepicker.FilePickerPlugin" "classpath": "io.capawesome.capacitorjs.plugins.filepicker.FilePickerPlugin"

1
android/app/src/main/res/xml/file_paths.xml

@ -2,4 +2,5 @@
<paths xmlns:android="http://schemas.android.com/apk/res/android"> <paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." /> <external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." /> <cache-path name="my_cache_images" path="." />
<files-path name="my_files" path="." />
</paths> </paths>

3
android/capacitor.settings.gradle

@ -11,5 +11,8 @@ project(':capacitor-camera').projectDir = new File('../node_modules/@capacitor/c
include ':capacitor-filesystem' include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android') 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' include ':capawesome-capacitor-file-picker'
project(':capawesome-capacitor-file-picker').projectDir = new File('../node_modules/@capawesome/capacitor-file-picker/android') project(':capawesome-capacitor-file-picker').projectDir = new File('../node_modules/@capawesome/capacitor-file-picker/android')

10
package-lock.json

@ -15,6 +15,7 @@
"@capacitor/core": "^6.2.0", "@capacitor/core": "^6.2.0",
"@capacitor/filesystem": "^6.0.0", "@capacitor/filesystem": "^6.0.0",
"@capacitor/ios": "^6.2.0", "@capacitor/ios": "^6.2.0",
"@capacitor/share": "^6.0.3",
"@capawesome/capacitor-file-picker": "^6.2.0", "@capawesome/capacitor-file-picker": "^6.2.0",
"@dicebear/collection": "^5.4.1", "@dicebear/collection": "^5.4.1",
"@dicebear/core": "^5.4.1", "@dicebear/core": "^5.4.1",
@ -2908,6 +2909,15 @@
"@capacitor/core": "^6.2.0" "@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": { "node_modules/@capawesome/capacitor-file-picker": {
"version": "6.2.0", "version": "6.2.0",
"resolved": "https://registry.npmjs.org/@capawesome/capacitor-file-picker/-/capacitor-file-picker-6.2.0.tgz", "resolved": "https://registry.npmjs.org/@capawesome/capacitor-file-picker/-/capacitor-file-picker-6.2.0.tgz",

1
package.json

@ -50,6 +50,7 @@
"@capacitor/core": "^6.2.0", "@capacitor/core": "^6.2.0",
"@capacitor/filesystem": "^6.0.0", "@capacitor/filesystem": "^6.0.0",
"@capacitor/ios": "^6.2.0", "@capacitor/ios": "^6.2.0",
"@capacitor/share": "^6.0.3",
"@capawesome/capacitor-file-picker": "^6.2.0", "@capawesome/capacitor-file-picker": "^6.2.0",
"@dicebear/collection": "^5.4.1", "@dicebear/collection": "^5.4.1",
"@dicebear/core": "^5.4.1", "@dicebear/core": "^5.4.1",

2
src/components/DataExportSection.vue

@ -145,7 +145,7 @@ export default class DataExportSection extends Vue {
} else if (this.platformCapabilities.hasFileSystem) { } else if (this.platformCapabilities.hasFileSystem) {
// Native platform: Write to app directory // Native platform: Write to app directory
const content = await blob.text(); const content = await blob.text();
await this.platformService.writeFile(fileName, content); await this.platformService.writeAndShareFile(fileName, content);
} }
this.$notify( this.$notify(

8
src/services/PlatformService.ts

@ -57,6 +57,14 @@ export interface PlatformService {
*/ */
writeFile(path: string, content: string): Promise<void>; writeFile(path: string, content: string): Promise<void>;
/**
* 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<void>;
/** /**
* Deletes a file at the specified path. * Deletes a file at the specified path.
* @param path - The path to the file to delete * @param path - The path to the file to delete

268
src/services/platforms/CapacitorPlatformService.ts

@ -5,7 +5,7 @@ import {
} from "../PlatformService"; } from "../PlatformService";
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem"; import { Filesystem, Directory, Encoding } from "@capacitor/filesystem";
import { Camera, CameraResultType, CameraSource } from "@capacitor/camera"; import { Camera, CameraResultType, CameraSource } from "@capacitor/camera";
import { FilePicker } from "@capawesome/capacitor-file-picker"; import { Share } from "@capacitor/share";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
/** /**
@ -156,16 +156,37 @@ export class CapacitorPlatformService implements PlatformService {
} }
/** /**
* Writes content to a file in the user-selected directory. * Writes content to a file in the app's safe storage and offers sharing.
* Opens a directory picker for the user to choose where to save. *
* @param path - Suggested filename * Platform-specific behavior:
* @param content - Content to write to the file * - Saves to app's Documents directory
* @throws Error if write operation fails * - 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<void> { async writeFile(fileName: string, content: string): Promise<void> {
try { try {
const logData = { const logData = {
targetPath: path, targetFileName: fileName,
contentLength: content.length, contentLength: content.length,
platform: this.getCapabilities().isIOS ? "iOS" : "Android", platform: this.getCapabilities().isIOS ? "iOS" : "Android",
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@ -175,146 +196,97 @@ export class CapacitorPlatformService implements PlatformService {
JSON.stringify(logData, null, 2), JSON.stringify(logData, null, 2),
); );
// Check and request storage permissions if needed // For Android, we need to handle content URIs differently
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) { if (this.getCapabilities().isIOS) {
const iosLogData = { // Write to app's Documents directory for iOS
originalPath: cleanPath, const writeResult = await Filesystem.writeFile({
timestamp: new Date().toISOString(), path: fileName,
}; data: content,
logger.log("Processing iOS path", JSON.stringify(iosLogData, null, 2)); directory: Directory.Data,
cleanPath = result.path; encoding: Encoding.UTF8,
} 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 writeSuccessLogData = {
const docIdMatch = cleanPath.match(/tree\/(.*?)(?:\/|$)/); path: writeResult.uri,
if (docIdMatch) {
const docId = docIdMatch[1];
const docIdLogData = {
docId,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
logger.log( logger.log(
"Extracted document ID:", "File write successful",
JSON.stringify(docIdLogData, null, 2), JSON.stringify(writeSuccessLogData, null, 2),
); );
// Use the document ID as the path // Offer to share the file
cleanPath = docId; try {
} await Share.share({
} title: "TimeSafari Backup",
} text: "Here is your TimeSafari backup file.",
url: writeResult.uri,
dialogTitle: "Share your backup",
});
const finalPath = cleanPath;
const finalPathLogData = {
fullPath: finalPath,
filename: path,
timestamp: new Date().toISOString(),
};
logger.log( logger.log(
"Final path details:", "Share dialog shown",
JSON.stringify(finalPathLogData, null, 2), JSON.stringify({ timestamp: new Date().toISOString() }, null, 2),
); );
} catch (shareError) {
// Write to the selected directory // Log share error but don't fail the operation
const writeLogData = { logger.error(
path: finalPath, "Share dialog failed",
contentLength: content.length, JSON.stringify(
{
error: shareError,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; },
logger.log( null,
"Attempting file write:", 2,
JSON.stringify(writeLogData, null, 2), ),
); );
}
try { } else {
if (this.getCapabilities().isIOS) { // For Android, first write to app's Documents directory
await Filesystem.writeFile({ const writeResult = await Filesystem.writeFile({
path: finalPath, path: fileName,
data: content, data: content,
directory: Directory.Documents, directory: Directory.Data,
recursive: true,
encoding: Encoding.UTF8, encoding: Encoding.UTF8,
}); });
} else {
// For Android, use the content URI directly const writeSuccessLogData = {
const androidPath = `Download/${path}`; path: writeResult.uri,
const directoryLogData = {
path: androidPath,
directory: Directory.ExternalStorage,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
logger.log( logger.log(
"Android path configuration:", "File write successful to app storage",
JSON.stringify(directoryLogData, null, 2), JSON.stringify(writeSuccessLogData, null, 2),
); );
await Filesystem.writeFile({ // Then share the file to let user choose where to save it
path: androidPath, try {
data: content, await Share.share({
directory: Directory.ExternalStorage, title: "TimeSafari Backup",
recursive: true, text: "Here is your TimeSafari backup file.",
encoding: Encoding.UTF8, url: writeResult.uri,
dialogTitle: "Save your backup",
}); });
}
const writeSuccessLogData = {
path: finalPath,
timestamp: new Date().toISOString(),
};
logger.log( logger.log(
"File write successful", "Share dialog shown for Android",
JSON.stringify(writeSuccessLogData, null, 2), JSON.stringify({ timestamp: new Date().toISOString() }, null, 2),
); );
} catch (writeError: unknown) { } catch (shareError) {
const error = writeError as Error; // Log share error but don't fail the operation
const writeErrorLogData = {
error: {
message: error.message,
name: error.name,
stack: error.stack,
},
path: finalPath,
contentLength: content.length,
timestamp: new Date().toISOString(),
};
logger.error( logger.error(
"File write failed:", "Share dialog failed for Android",
JSON.stringify(writeErrorLogData, null, 2), JSON.stringify(
{
error: shareError,
timestamp: new Date().toISOString(),
},
null,
2,
),
); );
throw new Error(`Failed to write file: ${error.message}`); }
} }
} catch (error: unknown) { } catch (error: unknown) {
const err = error as Error; const err = error as Error;
@ -330,9 +302,55 @@ export class CapacitorPlatformService implements PlatformService {
"Error in writeFile operation:", "Error in writeFile operation:",
JSON.stringify(finalErrorLogData, null, 2), JSON.stringify(finalErrorLogData, null, 2),
); );
throw new Error( throw new Error(`Failed to save file: ${err.message}`);
`Failed to save file to selected location: ${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<void> {
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}`);
} }
} }

Loading…
Cancel
Save