diff --git a/src/services/platforms/CapacitorPlatformService.ts b/src/services/platforms/CapacitorPlatformService.ts index 9f91732e..a32556e3 100644 --- a/src/services/platforms/CapacitorPlatformService.ts +++ b/src/services/platforms/CapacitorPlatformService.ts @@ -51,6 +51,11 @@ export class CapacitorPlatformService implements PlatformService { private initializationPromise: Promise | null = null; private operationQueue: Array = []; private isProcessingQueue: boolean = false; + + /** Permission request lock to prevent concurrent requests */ + private permissionRequestLock: Promise | null = null; + private permissionGranted: boolean = false; + private permissionChecked: boolean = false; constructor() { this.sqlite = new SQLiteConnection(CapacitorSQLite); @@ -277,84 +282,88 @@ export class CapacitorPlatformService implements PlatformService { return; } - // For Android, try to access external storage to check permissions - try { - // Try to list files in external storage to check permissions - await Filesystem.readdir({ - path: ".", - directory: Directory.External, + // Check if we already have permissions + if (this.permissionChecked && this.permissionGranted) { + logger.log("External storage permissions already granted", { + timestamp: new Date().toISOString(), }); - logger.log( - "External storage permissions already granted", - JSON.stringify({ timestamp: new Date().toISOString() }, null, 2), - ); return; - } catch (error) { - const err = error as Error; - const errorLogData = { - error: { - message: err.message, - name: err.name, - stack: err.stack, - }, - timestamp: new Date().toISOString(), - }; - - // Check for actual permission errors - if ( - err.message.includes("permission") || - err.message.includes("access") || - err.message.includes("denied") || - err.message.includes("User denied storage permission") - ) { - 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.readdir({ - path: ".", - directory: Directory.External, - }); - logger.log( - "External storage permissions granted after request", - JSON.stringify({ timestamp: new Date().toISOString() }, null, 2), - ); - return; - } catch (retryError: unknown) { - const retryErr = retryError as Error; - - // If permission is still denied, log it but don't throw an error - // This allows the app to continue with limited functionality - if (retryErr.message.includes("User denied storage permission")) { - logger.warn( - "External storage permissions denied by user, continuing with limited functionality", - JSON.stringify({ - error: retryErr.message, - timestamp: new Date().toISOString() - }, null, 2), - ); - return; // Don't throw error, just return - } - - throw new Error( - `Failed to obtain external storage permissions: ${retryErr.message}`, - ); - } - } + } - // For any other error, log it but don't treat as permission error - logger.log( - "Unexpected error during permission check, proceeding anyway", - JSON.stringify(errorLogData, null, 2), - ); + // If a permission request is already in progress, wait for it + if (this.permissionRequestLock) { + logger.log("Permission request already in progress, waiting...", { + timestamp: new Date().toISOString(), + }); + await this.permissionRequestLock; return; } + + // Create a new permission request lock + this.permissionRequestLock = this._requestStoragePermissions(); + + try { + await this.permissionRequestLock; + this.permissionGranted = true; + this.permissionChecked = true; + logger.log("Storage permissions granted successfully", { + timestamp: new Date().toISOString(), + }); + } catch (error) { + this.permissionGranted = false; + this.permissionChecked = true; + const errorMessage = error instanceof Error ? error.message : String(error); + logger.warn("Storage permissions denied, continuing with limited functionality", { + error: errorMessage, + timestamp: new Date().toISOString(), + }); + // Don't throw error - allow app to continue with limited functionality + } finally { + this.permissionRequestLock = null; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error("Error checking storage permissions:", { + error: errorMessage, + timestamp: new Date().toISOString(), + }); + // Don't throw error - allow app to continue with limited functionality + } + } + + /** + * Internal method to request storage permissions + * @returns Promise that resolves when permissions are granted or denied + */ + private async _requestStoragePermissions(): Promise { + try { + // Try to read external storage to trigger permission request + await Filesystem.readdir({ + path: ".", + directory: Directory.ExternalStorage, + }); + + logger.log("External storage permissions already granted", { + timestamp: new Date().toISOString(), + }); } catch (error) { - logger.error("Error in checkStoragePermissions:", error); + const errorMessage = error instanceof Error ? error.message : String(error); + + // Check if this is a permission denial + if (errorMessage.includes("user denied permission") || + errorMessage.includes("permission request")) { + logger.warn("User denied storage permission", { + error: errorMessage, + timestamp: new Date().toISOString(), + }); + throw new Error("Storage permission denied by user"); + } + + // For other errors, log but don't treat as permission denial + logger.warn("Storage access error (not permission-related):", { + error: errorMessage, + timestamp: new Date().toISOString(), + }); throw error; } } @@ -1966,213 +1975,172 @@ export class CapacitorPlatformService implements PlatformService { } /** - * Enhanced file discovery that searches multiple user-accessible locations - * This helps find files regardless of where users chose to save them + * Lists user-accessible files with enhanced discovery from multiple storage locations. + * This method tries multiple directories and handles permission denials gracefully. * @returns Promise resolving to array of file information */ async listUserAccessibleFilesEnhanced(): Promise> { + const allFiles: Array<{name: string, uri: string, size?: number, path?: string}> = []; + try { - const allFiles: Array<{name: string, uri: string, size?: number, path?: string}> = []; + // Check permissions once at the beginning + const hasPermissions = await this.hasStoragePermissions(); if (this.getCapabilities().isIOS) { - // iOS: Documents directory - const result = await Filesystem.readdir({ - path: ".", - directory: Directory.Documents, - }); - const files = result.files.map((file) => ({ - name: typeof file === "string" ? file : file.name, - uri: `file://${file.uri || file}`, - size: typeof file === "string" ? undefined : file.size, - path: "Documents" - })); - allFiles.push(...files); - } else { - // Android: Multiple locations with recursive search - - // 1. App's external storage directory + // iOS: List files in Documents directory (persistent, accessible via Files app) try { - const appStorageResult = await Filesystem.readdir({ - path: "TimeSafari", - directory: Directory.ExternalStorage, + const result = await Filesystem.readdir({ + path: ".", + directory: Directory.Documents, }); - // Log full readdir output for TimeSafari - logger.log("[CapacitorPlatformService] Android TimeSafari readdir full result:", { - path: "TimeSafari", - directory: "ExternalStorage", - files: appStorageResult.files, - fileCount: appStorageResult.files.length, - fileDetails: appStorageResult.files.map((file, index) => ({ - index, - name: typeof file === "string" ? file : file.name, - type: typeof file === "string" ? "string" : "object", - hasUri: typeof file === "string" ? false : !!file.uri, - hasSize: typeof file === "string" ? false : !!file.size, - fullObject: file - })), + const files = result.files + .filter((file) => typeof file === "object" && file.type === "file") + .map((file) => { + const fileObj = file as any; + return { + name: fileObj.name, + uri: fileObj.uri, + size: fileObj.size, + path: "Documents", + }; + }); + + allFiles.push(...files); + + logger.log("[CapacitorPlatformService] iOS Documents files found:", { + fileCount: files.length, timestamp: new Date().toISOString(), }); - - const appStorageFiles = appStorageResult.files.map((file) => ({ - name: typeof file === "string" ? file : file.name, - uri: `file://${file.uri || file}`, - size: typeof file === "string" ? undefined : file.size, - path: "TimeSafari" - })); - allFiles.push(...appStorageFiles); } catch (error) { - const err = error as Error; - if (err.message.includes("User denied storage permission")) { - logger.warn("[CapacitorPlatformService] Storage permission denied for TimeSafari, skipping"); - } else { - logger.warn("[CapacitorPlatformService] Could not read TimeSafari external storage:", error); - } + const errorMessage = error instanceof Error ? error.message : String(error); + logger.warn("[CapacitorPlatformService] Could not read iOS Documents:", { + error: errorMessage, + timestamp: new Date().toISOString(), + }); } - - // 2. Common user-chosen locations (if accessible) with recursive search - const commonPaths = [ - "Download", - "Documents", - "Backups", - "TimeSafari", - "Data" - ]; - - for (const path of commonPaths) { + } else { + // Android: Try multiple storage locations + if (hasPermissions) { + // 1. App's external storage directory try { - const result = await Filesystem.readdir({ - path: path, + const appStorageResult = await Filesystem.readdir({ + path: "TimeSafari", directory: Directory.ExternalStorage, }); - // Log full readdir output for debugging - logger.log(`[CapacitorPlatformService] Android ${path} readdir full result:`, { - path: path, - directory: "ExternalStorage", - files: result.files, - fileCount: result.files.length, - fileDetails: result.files.map((file, index) => ({ - index, - name: typeof file === "string" ? file : file.name, - type: typeof file === "string" ? "string" : "object", - hasUri: typeof file === "string" ? false : !!file.uri, - hasSize: typeof file === "string" ? false : !!file.size, - fullObject: file - })), + const appFiles = appStorageResult.files + .filter((file) => typeof file === "object" && file.type === "file") + .map((file) => { + const fileObj = file as any; + return { + name: fileObj.name, + uri: fileObj.uri, + size: fileObj.size, + path: "TimeSafari", + }; + }); + + allFiles.push(...appFiles); + + logger.log("[CapacitorPlatformService] Android TimeSafari files found:", { + fileCount: appFiles.length, timestamp: new Date().toISOString(), }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.warn("[CapacitorPlatformService] Could not read TimeSafari external storage:", { + error: errorMessage, + timestamp: new Date().toISOString(), + }); + } + + // 2. Downloads/TimeSafari directory + try { + const downloadsResult = await Filesystem.readdir({ + path: "Download/TimeSafari", + directory: Directory.ExternalStorage, + }); - // Process each entry (file or directory) - const relevantFiles = []; - for (const file of result.files) { - const fileName = typeof file === "string" ? file : file.name; - const name = fileName.toLowerCase(); - - // Check if it's a directory by trying to get file stats - let isDirectory = false; - try { - const stat = await Filesystem.stat({ - path: `${path}/${fileName}`, - directory: Directory.ExternalStorage - }); - isDirectory = stat.type === 'directory'; - } catch (statError) { - // If stat fails, assume it's a file - isDirectory = false; - } - - if (isDirectory) { - // RECURSIVELY SEARCH DIRECTORY for backup files - logger.log(`[CapacitorPlatformService] Recursively searching directory: ${fileName} in ${path}`); - try { - const subDirResult = await Filesystem.readdir({ - path: `${path}/${fileName}`, - directory: Directory.ExternalStorage, - }); - - // Process files in subdirectory - for (const subFile of subDirResult.files) { - const subFileName = typeof subFile === "string" ? subFile : subFile.name; - const subName = subFileName.toLowerCase(); - - // Check if subfile matches backup criteria - const matchesBackupCriteria = subName.includes('timesafari') || - subName.includes('backup') || - subName.includes('contacts') || - subName.endsWith('.json'); - - if (matchesBackupCriteria) { - relevantFiles.push({ - name: subFileName, - uri: `file://${subFile.uri || subFile}`, - size: typeof subFile === "string" ? undefined : subFile.size, - path: `${path}/${fileName}` - }); - logger.log(`[CapacitorPlatformService] Found backup file in subdirectory: ${subFileName} in ${path}/${fileName}`); - } - } - } catch (subDirError) { - const subDirErr = subDirError as Error; - if (subDirErr.message.includes("User denied storage permission")) { - logger.warn(`[CapacitorPlatformService] Storage permission denied for subdirectory ${path}/${fileName}, skipping`); - } else { - logger.warn(`[CapacitorPlatformService] Could not read subdirectory ${path}/${fileName}:`, subDirError); - } - } - } else { - // Check if file matches backup criteria - const matchesBackupCriteria = name.includes('timesafari') || - name.includes('backup') || - name.includes('contacts') || - name.endsWith('.json'); - - if (matchesBackupCriteria) { - relevantFiles.push({ - name: fileName, - uri: `file://${file.uri || file}`, - size: typeof file === "string" ? undefined : file.size, - path: path - }); - } else { - logger.log(`[CapacitorPlatformService] Excluding non-backup file: ${fileName} in ${path}`); - } - } - } - - if (relevantFiles.length > 0) { - logger.log(`[CapacitorPlatformService] Found ${relevantFiles.length} relevant files in ${path}:`, { - files: relevantFiles.map(f => f.name), - timestamp: new Date().toISOString(), + const downloadFiles = downloadsResult.files + .filter((file) => typeof file === "object" && file.type === "file") + .map((file) => { + const fileObj = file as any; + return { + name: fileObj.name, + uri: fileObj.uri, + size: fileObj.size, + path: "Download/TimeSafari", + }; }); - allFiles.push(...relevantFiles); - } - } catch (pathError) { - const pathErr = pathError as Error; - if (pathErr.message.includes("User denied storage permission")) { - logger.warn(`[CapacitorPlatformService] Storage permission denied for ${path}, skipping`); - } else { - logger.warn(`[CapacitorPlatformService] Could not read ${path}:`, pathError); - } + + allFiles.push(...downloadFiles); + + logger.log("[CapacitorPlatformService] Android Downloads/TimeSafari files found:", { + fileCount: downloadFiles.length, + timestamp: new Date().toISOString(), + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.warn("[CapacitorPlatformService] Could not read Downloads/TimeSafari:", { + error: errorMessage, + timestamp: new Date().toISOString(), + }); } + } else { + logger.log("[CapacitorPlatformService] Storage permissions not available, skipping external storage", { + timestamp: new Date().toISOString(), + }); } } - // Remove duplicates based on filename and path - const uniqueFiles = allFiles.filter((file, index, self) => - index === self.findIndex(f => f.name === file.name && f.path === file.path) - ); + // Always try app data directory as fallback + try { + const dataResult = await Filesystem.readdir({ + path: ".", + directory: Directory.Data, + }); + + const dataFiles = dataResult.files + .filter((file) => typeof file === "object" && file.type === "file") + .map((file) => { + const fileObj = file as any; + return { + name: fileObj.name, + uri: fileObj.uri, + size: fileObj.size, + path: "Data", + }; + }); + + allFiles.push(...dataFiles); + + logger.log("[CapacitorPlatformService] App data directory files found:", { + fileCount: dataFiles.length, + timestamp: new Date().toISOString(), + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.warn("[CapacitorPlatformService] Could not read app data directory:", { + error: errorMessage, + timestamp: new Date().toISOString(), + }); + } logger.log("[CapacitorPlatformService] Enhanced file discovery results:", { - total: uniqueFiles.length, - files: uniqueFiles.map(f => ({ name: f.name, path: f.path })), + totalFiles: allFiles.length, + hasPermissions, platform: this.getCapabilities().isIOS ? "iOS" : "Android", timestamp: new Date().toISOString(), }); - return uniqueFiles; + return allFiles; } catch (error) { - logger.error("[CapacitorPlatformService] Enhanced file discovery failed:", error); + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error("[CapacitorPlatformService] Error in enhanced file discovery:", { + error: errorMessage, + timestamp: new Date().toISOString(), + }); return []; } } @@ -2605,4 +2573,47 @@ export class CapacitorPlatformService implements PlatformService { // Android 10 (API 29) and above have stricter storage restrictions return androidVersion !== null && androidVersion >= 29; } + + /** + * Resets the permission state (useful for testing or when permissions change) + */ + private resetPermissionState(): void { + this.permissionGranted = false; + this.permissionChecked = false; + this.permissionRequestLock = null; + logger.log("[CapacitorPlatformService] Permission state reset", { + timestamp: new Date().toISOString(), + }); + } + + /** + * Checks if storage permissions are available without requesting them + * @returns Promise resolving to true if permissions are available + */ + private async hasStoragePermissions(): Promise { + if (this.permissionChecked) { + return this.permissionGranted; + } + + // If not checked yet, check without requesting + try { + await Filesystem.readdir({ + path: ".", + directory: Directory.ExternalStorage, + }); + this.permissionGranted = true; + this.permissionChecked = true; + return true; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("user denied permission") || + errorMessage.includes("permission request")) { + this.permissionGranted = false; + this.permissionChecked = true; + return false; + } + // For other errors, assume we don't have permissions + return false; + } + } }