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
This commit is contained in:
@@ -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')
|
||||
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="my_images" path="." />
|
||||
<cache-path name="my_cache_images" path="." />
|
||||
<files-path name="my_files" path="." />
|
||||
</paths>
|
||||
@@ -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')
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -57,6 +57,14 @@ export interface PlatformService {
|
||||
*/
|
||||
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.
|
||||
* @param path - The path to the file to delete
|
||||
|
||||
@@ -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<void> {
|
||||
async writeFile(fileName: string, content: string): Promise<void> {
|
||||
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,
|
||||
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,
|
||||
});
|
||||
}
|
||||
// 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: finalPath,
|
||||
path: writeResult.uri,
|
||||
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,
|
||||
|
||||
// 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.error(
|
||||
"File write failed:",
|
||||
JSON.stringify(writeErrorLogData, null, 2),
|
||||
logger.log(
|
||||
"File write successful to app storage",
|
||||
JSON.stringify(writeSuccessLogData, 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<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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user