Browse Source

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.
pull/130/head
Matthew Raymer 1 month ago
parent
commit
0b528af2a6
  1. 3
      BUILDING.md
  2. BIN
      android/.gradle/file-system.probe
  3. 2
      android/app/src/main/AndroidManifest.xml
  4. 6
      package-lock.json
  5. 296
      src/services/platforms/CapacitorPlatformService.ts
  6. 10
      src/utils/logger.ts

3
BUILDING.md

@ -215,6 +215,9 @@ Prerequisites: Android Studio with SDK installed
rm -rf dist
npm run build:web
npm run build:capacitor
cd android
./gradlew clean
./gradlew assembleDebug
```
2. Update Android project with latest build:

BIN
android/.gradle/file-system.probe

Binary file not shown.

2
android/app/src/main/AndroidManifest.xml

@ -39,4 +39,6 @@
<!-- Permissions -->
<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>

6
package-lock.json

@ -12313,9 +12313,9 @@
}
},
"node_modules/bignumber.js": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.2.0.tgz",
"integrity": "sha512-JocpCSOixzy5XFJi2ub6IMmV/G9i8Lrm2lZvwBv9xPdglmZM0ufDVBbjbrfU/zuLvBfD7Bv2eYxz9i+OHTgkew==",
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.2.1.tgz",
"integrity": "sha512-+NzaKgOUvInq9TIUZ1+DRspzf/HApkCwD4btfuasFTdrfnOxqx853TgDpMolp+uv4RpRp7bPcEU2zKr9+fRmyw==",
"license": "MIT",
"engines": {
"node": "*"

296
src/services/platforms/CapacitorPlatformService.ts

@ -3,7 +3,7 @@ import {
PlatformService,
PlatformCapabilities,
} from "../PlatformService";
import { Filesystem, Directory } from "@capacitor/filesystem";
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem";
import { Camera, CameraResultType, CameraSource } from "@capacitor/camera";
import { FilePicker } from "@capawesome/capacitor-file-picker";
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.
* @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> {
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
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
let cleanPath = result.path;
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;
} else {
// For Android, extract the actual path from the content URI
const pathMatch = result.path.match(/tree\/(.*?)(?:\/|$)/);
logger.log("Path match result:", pathMatch);
if (pathMatch) {
const decodedPath = decodeURIComponent(pathMatch[1]);
logger.log("Decoded path:", decodedPath);
// Convert primary:Download to /storage/emulated/0/Download
cleanPath = decodedPath.replace('primary:', '/storage/emulated/0/');
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;
}
}
}
// 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);
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
await Filesystem.writeFile({
const writeLogData = {
path: finalPath,
data: content,
directory: Directory.External,
recursive: true,
});
contentLength: content.length,
timestamp: new Date().toISOString(),
};
logger.log(
"Attempting file write:",
JSON.stringify(writeLogData, null, 2),
);
} catch (error) {
logger.error("Error saving file:", error);
throw new Error("Failed to save file to selected location");
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}`,
);
}
}

10
src/utils/logger.ts

@ -21,7 +21,10 @@ function safeStringify(obj: unknown) {
export const logger = {
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
console.log(message, ...args);
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
@ -29,7 +32,10 @@ export const logger = {
}
},
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
console.warn(message, ...args);
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";

Loading…
Cancel
Save