From 0b528af2a6ca9fbac94722b91d3faf4e1718cf08 Mon Sep 17 00:00:00 2001 From: Matthew Raymer Date: Wed, 9 Apr 2025 13:05:42 +0000 Subject: [PATCH] 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. --- BUILDING.md | 3 + android/.gradle/file-system.probe | Bin 8 -> 8 bytes android/app/src/main/AndroidManifest.xml | 2 + package-lock.json | 6 +- .../platforms/CapacitorPlatformService.ts | 296 ++++++++++++++++-- src/utils/logger.ts | 10 +- 6 files changed, 281 insertions(+), 36 deletions(-) diff --git a/BUILDING.md b/BUILDING.md index cc8f77b2..141dc3b2 100644 --- a/BUILDING.md +++ b/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: diff --git a/android/.gradle/file-system.probe b/android/.gradle/file-system.probe index 82c59317c7c73fc255619cc24e2b3fb86c61fdb6..44390a335b6402f9ddb8b68a72a8e49b763285fe 100644 GIT binary patch literal 8 PcmZQzV4Nn^HRTNe2NnXn literal 8 PcmZQzV4NnY{^B + + diff --git a/package-lock.json b/package-lock.json index 57cde520..cc5e7d01 100644 --- a/package-lock.json +++ b/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": "*" diff --git a/src/services/platforms/CapacitorPlatformService.ts b/src/services/platforms/CapacitorPlatformService.ts index 7a185017..68ce715d 100644 --- a/src/services/platforms/CapacitorPlatformService.ts +++ b/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 { + 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 { 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}`, + ); } } diff --git a/src/utils/logger.ts b/src/utils/logger.ts index a0921b40..86389c47 100644 --- a/src/utils/logger.ts +++ b/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) : "";