Browse Source

fix: implement permission request lock and optimize file access

- Add permission request lock to prevent concurrent requests
- Implement permission state tracking to avoid redundant checks
- Optimize file discovery with single permission check
- Enhance error handling for permission denials
- Add graceful degradation when permissions are denied
- Improve structured logging for better debugging

Resolves "Can request only one set of permissions at a time" warnings
Reduces redundant permission checks and file system operations
Ensures app continues to function with limited permissions
capacitor-local-save
Matthew Raymer 2 days ago
parent
commit
f7ed05d13f
  1. 455
      src/services/platforms/CapacitorPlatformService.ts

455
src/services/platforms/CapacitorPlatformService.ts

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

Loading…
Cancel
Save