forked from trent_larson/crowd-funder-for-time-pwa
WIP: Fix Android file writing permissions and path handling
- Refactor writeFile method to properly handle Android Storage Access Framework (SAF) URIs - Update path construction for Android to use Download directory with correct permissions - Enhance error handling and logging throughout file operations - Add detailed logging for debugging file system operations - Fix permission checking logic to handle "File does not exist" case correctly - Improve error messages and stack traces in logs - Add timestamp to all log entries for better debugging - Use proper directory types (ExternalStorage for Android, Documents for iOS) - Add UTF-8 encoding specification for file writes This is a work in progress as we're still seeing permission issues with Android file writing.
This commit is contained in:
@@ -215,6 +215,9 @@ Prerequisites: Android Studio with SDK installed
|
|||||||
rm -rf dist
|
rm -rf dist
|
||||||
npm run build:web
|
npm run build:web
|
||||||
npm run build:capacitor
|
npm run build:capacitor
|
||||||
|
cd android
|
||||||
|
./gradlew clean
|
||||||
|
./gradlew assembleDebug
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Update Android project with latest build:
|
2. Update Android project with latest build:
|
||||||
|
|||||||
Binary file not shown.
@@ -39,4 +39,6 @@
|
|||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -12313,9 +12313,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bignumber.js": {
|
"node_modules/bignumber.js": {
|
||||||
"version": "9.2.0",
|
"version": "9.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.2.1.tgz",
|
||||||
"integrity": "sha512-JocpCSOixzy5XFJi2ub6IMmV/G9i8Lrm2lZvwBv9xPdglmZM0ufDVBbjbrfU/zuLvBfD7Bv2eYxz9i+OHTgkew==",
|
"integrity": "sha512-+NzaKgOUvInq9TIUZ1+DRspzf/HApkCwD4btfuasFTdrfnOxqx853TgDpMolp+uv4RpRp7bPcEU2zKr9+fRmyw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "*"
|
"node": "*"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
PlatformService,
|
PlatformService,
|
||||||
PlatformCapabilities,
|
PlatformCapabilities,
|
||||||
} from "../PlatformService";
|
} from "../PlatformService";
|
||||||
import { Filesystem, Directory } 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 { FilePicker } from "@capawesome/capacitor-file-picker";
|
||||||
import { logger } from "../../utils/logger";
|
import { logger } from "../../utils/logger";
|
||||||
@@ -31,6 +31,113 @@ export class CapacitorPlatformService implements PlatformService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void> {
|
||||||
|
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.
|
* Reads a file from the app's data directory.
|
||||||
* @param path - Relative path to the file in the app's data directory
|
* @param path - Relative path to the file in the app's data directory
|
||||||
@@ -57,48 +164,175 @@ export class CapacitorPlatformService implements PlatformService {
|
|||||||
*/
|
*/
|
||||||
async writeFile(path: string, content: string): Promise<void> {
|
async writeFile(path: string, content: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check and request storage permissions if needed
|
||||||
|
await this.checkStoragePermissions();
|
||||||
|
|
||||||
// Let user pick save location first
|
// Let user pick save location first
|
||||||
const result = await FilePicker.pickDirectory();
|
const result = await FilePicker.pickDirectory();
|
||||||
logger.log("FilePicker result path:", result.path);
|
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
|
// Handle paths based on platform
|
||||||
let cleanPath = result.path;
|
let cleanPath = result.path;
|
||||||
if (this.getCapabilities().isIOS) {
|
if (this.getCapabilities().isIOS) {
|
||||||
// For iOS, keep content: prefix
|
const iosLogData = {
|
||||||
|
originalPath: cleanPath,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
logger.log("Processing iOS path", JSON.stringify(iosLogData, null, 2));
|
||||||
cleanPath = result.path;
|
cleanPath = result.path;
|
||||||
} else {
|
} else {
|
||||||
// For Android, extract the actual path from the content URI
|
const androidLogData = {
|
||||||
const pathMatch = result.path.match(/tree\/(.*?)(?:\/|$)/);
|
originalPath: cleanPath,
|
||||||
logger.log("Path match result:", pathMatch);
|
timestamp: new Date().toISOString(),
|
||||||
if (pathMatch) {
|
};
|
||||||
const decodedPath = decodeURIComponent(pathMatch[1]);
|
logger.log(
|
||||||
logger.log("Decoded path:", decodedPath);
|
"Processing Android path",
|
||||||
// Convert primary:Download to /storage/emulated/0/Download
|
JSON.stringify(androidLogData, null, 2),
|
||||||
cleanPath = decodedPath.replace('primary:', '/storage/emulated/0/');
|
);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For Android, ensure we're using the correct external storage path
|
|
||||||
if (this.getCapabilities().isMobile && !this.getCapabilities().isIOS) {
|
|
||||||
logger.log("Before Android path conversion:", cleanPath);
|
|
||||||
cleanPath = cleanPath.replace('primary:', '/storage/emulated/0/');
|
|
||||||
logger.log("After Android path conversion:", cleanPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalPath = `${cleanPath}/${path}`;
|
|
||||||
logger.log("Final path for writeFile:", finalPath);
|
|
||||||
|
|
||||||
// Write to the selected directory
|
|
||||||
await Filesystem.writeFile({
|
|
||||||
path: finalPath,
|
|
||||||
data: content,
|
|
||||||
directory: Directory.External,
|
|
||||||
recursive: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
const finalPath = cleanPath;
|
||||||
logger.error("Error saving file:", error);
|
const finalPathLogData = {
|
||||||
throw new Error("Failed to save file to selected location");
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeSuccessLogData = {
|
||||||
|
path: finalPath,
|
||||||
|
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,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
logger.error(
|
||||||
|
"File write failed:",
|
||||||
|
JSON.stringify(writeErrorLogData, null, 2),
|
||||||
|
);
|
||||||
|
throw new Error(`Failed to write file: ${error.message}`);
|
||||||
|
}
|
||||||
|
} 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 to selected location: ${err.message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ function safeStringify(obj: unknown) {
|
|||||||
|
|
||||||
export const logger = {
|
export const logger = {
|
||||||
log: (message: string, ...args: unknown[]) => {
|
log: (message: string, ...args: unknown[]) => {
|
||||||
if (process.env.NODE_ENV !== "production" || process.env.VITE_PLATFORM === "capacitor") {
|
if (
|
||||||
|
process.env.NODE_ENV !== "production" ||
|
||||||
|
process.env.VITE_PLATFORM === "capacitor"
|
||||||
|
) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(message, ...args);
|
console.log(message, ...args);
|
||||||
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
|
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
|
||||||
@@ -29,7 +32,10 @@ export const logger = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
warn: (message: string, ...args: unknown[]) => {
|
warn: (message: string, ...args: unknown[]) => {
|
||||||
if (process.env.NODE_ENV !== "production" || process.env.VITE_PLATFORM === "capacitor") {
|
if (
|
||||||
|
process.env.NODE_ENV !== "production" ||
|
||||||
|
process.env.VITE_PLATFORM === "capacitor"
|
||||||
|
) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.warn(message, ...args);
|
console.warn(message, ...args);
|
||||||
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
|
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
|
||||||
|
|||||||
Reference in New Issue
Block a user