diff --git a/lib/utils/file_access.dart b/lib/utils/file_access.dart index 026fa57e..f686bc46 100644 --- a/lib/utils/file_access.dart +++ b/lib/utils/file_access.dart @@ -20,6 +20,22 @@ final _iosLegacyRelativeDocumentsPattern = RegExp( r'^Data/Application/[A-F0-9\-]+/Documents(?:/(.*))?$', caseSensitive: false, ); +final _iosNestedLegacyDocumentsPattern = RegExp( + r'/Documents/Data/Application/[A-F0-9\-]+/Documents(?:/(.*))?$', + caseSensitive: false, +); + +String _normalizeRecoveredIosSuffix(String suffix) { + final trimmed = suffix.trim(); + if (trimmed.isEmpty) return ''; + return trimmed.startsWith('/') ? trimmed.substring(1) : trimmed; +} + +String _joinRecoveredIosPath(String documentsPath, String suffix) { + final normalizedSuffix = _normalizeRecoveredIosSuffix(suffix); + if (normalizedSuffix.isEmpty) return documentsPath; + return '$documentsPath/$normalizedSuffix'; +} /// Checks if a path is a valid writable directory on iOS. /// Returns false if: @@ -43,6 +59,12 @@ bool isValidIosWritablePath(String path) { return false; } + // Reject stale paths where an old sandbox container path has been embedded + // inside the current Documents directory. + if (_iosNestedLegacyDocumentsPattern.hasMatch(path)) { + return false; + } + // Ensure path contains a valid subdirectory (Documents, tmp, Library, etc.) // This handles cases where FilePicker returns container root final containerPattern = RegExp( @@ -70,11 +92,19 @@ Future validateOrFixIosPath( if (!Platform.isIOS) return path; final trimmed = path.trim(); + final docDir = await getApplicationDocumentsDirectory(); + + final nestedLegacyMatch = _iosNestedLegacyDocumentsPattern.firstMatch( + trimmed, + ); + if (nestedLegacyMatch != null) { + return _joinRecoveredIosPath(docDir.path, nestedLegacyMatch.group(1) ?? ''); + } + if (isValidIosWritablePath(trimmed)) { return trimmed; } - final docDir = await getApplicationDocumentsDirectory(); final candidates = []; if (trimmed.isNotEmpty) { @@ -92,14 +122,8 @@ Future validateOrFixIosPath( trimmed, ); if (legacyRelativeMatch != null) { - final suffix = (legacyRelativeMatch.group(1) ?? '').trim(); - final normalizedSuffix = suffix.startsWith('/') - ? suffix.substring(1) - : suffix; candidates.add( - normalizedSuffix.isEmpty - ? docDir.path - : '${docDir.path}/$normalizedSuffix', + _joinRecoveredIosPath(docDir.path, legacyRelativeMatch.group(1) ?? ''), ); } @@ -109,7 +133,7 @@ Future validateOrFixIosPath( final index = trimmed.indexOf(documentsMarker); if (index >= 0) { final suffix = trimmed.substring(index + documentsMarker.length).trim(); - candidates.add(suffix.isEmpty ? docDir.path : '${docDir.path}/$suffix'); + candidates.add(_joinRecoveredIosPath(docDir.path, suffix)); } } @@ -181,6 +205,14 @@ IosPathValidationResult validateIosPath(String path) { ); } + if (_iosNestedLegacyDocumentsPattern.hasMatch(path)) { + return const IosPathValidationResult( + isValid: false, + errorReason: + 'Invalid iOS app folder path. Please choose App Documents or another local folder.', + ); + } + // Check for container root without subdirectory final containerPattern = RegExp( r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+',