From f2a893832570a0b7440e3078d1348611d7d8e580 Mon Sep 17 00:00:00 2001 From: Amoghhosamane Date: Tue, 7 Jul 2026 13:23:25 +0530 Subject: [PATCH] Fix file system sync issues, add granular encryption storage and clear all pod data button #66 --- lib/services/debug_data_service.dart | 178 +++++++++++++++++- .../media/media_pod_service_index.dart | 8 + lib/services/places/encrypted_places_io.dart | 112 ++++++----- lib/services/pod/pod_directory_service.dart | 1 + lib/widgets/settings/settings_actions.dart | 14 ++ 5 files changed, 269 insertions(+), 44 deletions(-) diff --git a/lib/services/debug_data_service.dart b/lib/services/debug_data_service.dart index a9e238b..2023416 100644 --- a/lib/services/debug_data_service.dart +++ b/lib/services/debug_data_service.dart @@ -16,7 +16,12 @@ library; import 'package:flutter/material.dart'; -import 'package:solidpod/solidpod.dart' show logoutPod; +import 'package:solidpod/solidpod.dart' + show + logoutPod, + getResourcesInContainer, + deleteResource, + ResourceContentType; import 'package:geopod/app.dart'; import 'package:geopod/services/media/media_pod_service.dart'; @@ -25,6 +30,7 @@ import 'package:geopod/services/places/places_cache_manager.dart'; import 'package:geopod/services/places/places_cache_persistence.dart'; import 'package:geopod/services/places/places_cache_service.dart'; import 'package:geopod/services/places_service.dart' show placesChangeNotifier; +import 'package:geopod/services/pod/pod_auth.dart'; import 'package:geopod/services/pod/pod_directory_service.dart'; import 'package:geopod/services/pod/pod_file_system.dart'; @@ -179,4 +185,174 @@ class DebugDataService { return const DeleteAllResult(success: true); } + + /// Delete ALL data in the connected Pod (except profile directory) and clear caches. + static Future clearAllPodData(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Clear All Pod Data'), + content: const Text( + 'This will permanently delete EVERYTHING inside your connected Solid Pod ' + '(except your profile card so you can log in again) and log you out:\n\n' + '• All folders like geopod/, private/, public/, etc.\n' + '• All other apps\' files and folders\n' + '• All local caches\n\n' + 'You will be automatically logged out afterwards.\n\n' + 'WARNING: This action cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + child: const Text('Clear All'), + ), + ], + ), + ); + + if (confirmed != true || !context.mounted) return; + + // Show loading overlay. + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const Center(child: CircularProgressIndicator()), + ); + + DeleteAllResult result; + try { + result = await _performWipePod(); + } catch (e) { + result = DeleteAllResult(success: false, error: e.toString()); + } + + if (!context.mounted) return; + Navigator.pop(context); // Close loading + + if (result.success) { + await logoutPod(); + + if (!context.mounted) return; + + await Navigator.of(context, rootNavigator: true).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const App()), + (_) => false, + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error clearing pod data: ${result.error}'), + backgroundColor: Colors.red, + ), + ); + } + } + + static Future _performWipePod() async { + final baseUrl = await PodAuth.getPodBaseUrl(); + if (baseUrl == null || baseUrl.isEmpty) { + return const DeleteAllResult(success: false, error: 'Not authenticated'); + } + + try { + final resources = await getResourcesInContainer(baseUrl); + + // Delete each top-level entry in parallel, except profile. + await Future.wait( + resources.subDirs.map((subDirRef) async { + final subDirAbsUrl = _resolveResourceUrl(subDirRef, baseUrl); + if (subDirAbsUrl.toLowerCase().endsWith('/profile/')) { + return; // Skip profile + } + await _deleteContainerByUrl(subDirAbsUrl); + }), + ); + + // Also delete any top-level files (except ACL/meta of root, but delete other files) + await Future.wait( + resources.files.map((fileRef) async { + final fileAbsUrl = _resolveResourceUrl(fileRef, baseUrl); + final name = fileRef.split('/').last.toLowerCase(); + if (name == 'card' || name == 'card.acl' || name == 'card.meta') { + return; + } + try { + await deleteResource('$fileAbsUrl.acl', ResourceContentType.any); + } catch (_) {} + try { + await deleteResource(fileAbsUrl, ResourceContentType.any); + } catch (_) {} + }), + ); + + // ── Clear all in-memory and persistent caches ───────────────────────── + PlacesCacheManager().clearCache(); + await Future.wait([ + PlacesCachePersistence.clearPodPlacesCache(), + EncryptedPlacesService.resetSessionState(), + ]); + EncryptedPlacesService.clearCache(); + MediaPodService.clearCache(); + await PlacesCacheService.clearPodCacheOnly(); + + // ── Notify all listeners ────────────────────────────────────────────── + placesChangeNotifier.value++; + PodDirectoryService.clearCache(); + PodDirectoryService.notifyChange(); + + return const DeleteAllResult(success: true); + } catch (e) { + return DeleteAllResult(success: false, error: e.toString()); + } + } + + static Future _deleteContainerByUrl(String containerUrl) async { + final url = containerUrl.endsWith('/') ? containerUrl : '$containerUrl/'; + try { + final resources = await getResourcesInContainer(url); + final base = url; + + // Depth-first recursion. + for (final subDirRef in resources.subDirs) { + final subDirAbsUrl = _resolveResourceUrl(subDirRef, base); + if (subDirAbsUrl.toLowerCase().endsWith('/profile/')) { + continue; + } + await _deleteContainerByUrl(subDirAbsUrl); + } + + for (final fileRef in resources.files) { + final fileAbsUrl = _resolveResourceUrl(fileRef, base); + try { + await deleteResource('$fileAbsUrl.acl', ResourceContentType.any); + } catch (_) {} + try { + await deleteResource(fileAbsUrl, ResourceContentType.any); + } catch (_) {} + } + + try { + await deleteResource('$base.acl', ResourceContentType.any); + } catch (_) {} + } catch (_) {} + + try { + await deleteResource(url, ResourceContentType.directory); + } catch (_) {} + } + + static String _resolveResourceUrl(String ref, String baseUrl) { + if (ref.startsWith('http://') || ref.startsWith('https://')) { + return ref; + } + return '$baseUrl$ref'; + } } diff --git a/lib/services/media/media_pod_service_index.dart b/lib/services/media/media_pod_service_index.dart index 7876716..fcc8d33 100644 --- a/lib/services/media/media_pod_service_index.dart +++ b/lib/services/media/media_pod_service_index.dart @@ -173,6 +173,14 @@ Future _writeIndex(MediaType type, List items) async { // writePod throws on failure (handled below), so reaching here is success. // Update the cache so subsequent reads are still fast. _setCache(type, List.from(items)); + + // Invalidate the directory cache and notify the file browser. + final dirPath = type == MediaType.audio + ? getAudioDirPath() + : getVideoDirPath(); + PodDirectoryService.invalidateCache(dirPath); + PodDirectoryService.notifyChange(); + return true; } catch (e) { debugPrint('MediaPodService._writeIndex error: $e'); diff --git a/lib/services/places/encrypted_places_io.dart b/lib/services/places/encrypted_places_io.dart index bd79d8c..aee504a 100644 --- a/lib/services/places/encrypted_places_io.dart +++ b/lib/services/places/encrypted_places_io.dart @@ -20,6 +20,7 @@ import 'package:solidpod/solidpod.dart'; import 'package:geopod/models/place.dart'; import 'package:geopod/services/places/encrypted_places_paths.dart'; +import 'package:geopod/services/pod/pod_directory_service.dart'; import 'package:geopod/services/pod/pod_file_system.dart'; /// Ensure the encrypted places directory's ACL and encryption key are set up. @@ -55,7 +56,7 @@ Future<(bool success, bool dirCreated)> ensureEncryptedPlacesDir( } /// Read encrypted places from Pod. -/// Optimized: tries to read directly without checking existence first. +/// Loads granular individual files or migrates legacy aggregate file. Future> fetchEncryptedPlacesFromPod() async { final places = []; @@ -64,24 +65,38 @@ Future> fetchEncryptedPlacesFromPod() async { return places; } - // Read encrypted content directly using relative path - // If file doesn't exist, readPod will return fail status - final filePath = getEncryptedPlacesFilePath(); - final content = await readPod(filePath); + final dirPath = 'data/$encryptedPlacesDirName'; + final dirItems = await PodDirectoryService.listDirectory( + dirPath, + forceRefresh: true, + ); - // Handle non-existent file or errors gracefully - if (content == SolidFunctionCallStatus.notLoggedIn.toString() || - content == SolidFunctionCallStatus.fail.toString() || - content.isEmpty) { - return places; - } + final individualFiles = dirItems.where((item) => + !item.isDirectory && + item.name.startsWith(encryptedPlaceFilePrefix) && + item.name.endsWith(encryptedPlaceFileExtension)); + + if (individualFiles.isNotEmpty) { + // Load individual files in parallel + final futures = >[]; + for (final item in individualFiles) { + final cleanPath = item.path.startsWith('data/') + ? item.path.substring('data/'.length) + : item.path; + futures.add(readPod(cleanPath)); + } - // Parse JSON content directly. + final contents = await Future.wait(futures); + for (final content in contents) { + if (content == null || + content == SolidFunctionCallStatus.notLoggedIn.toString() || + content == SolidFunctionCallStatus.fail.toString() || + content.isEmpty) { + continue; + } - try { - final jsonList = jsonDecode(content); - if (jsonList is List) { - for (final item in jsonList) { + try { + final item = jsonDecode(content); if (item is Map) { final place = Place.fromJson( item, @@ -90,10 +105,44 @@ Future> fetchEncryptedPlacesFromPod() async { ); places.add(place); } + } catch (e) { + debugPrint('Failed to parse encrypted place JSON: $e'); + } + } + } else { + // Migration path: Check if legacy aggregate file exists + final aggregatePath = getEncryptedPlacesFilePath(); + final content = await readPod(aggregatePath); + if (content != SolidFunctionCallStatus.notLoggedIn.toString() && + content != SolidFunctionCallStatus.fail.toString() && + content.isNotEmpty) { + try { + final jsonList = jsonDecode(content); + if (jsonList is List) { + for (final item in jsonList) { + if (item is Map) { + final place = Place.fromJson( + item, + isLocalSource: false, + isEncryptedSource: true, + ); + places.add(place); + } + } + } + + // Migrate by writing individual files + if (places.isNotEmpty) { + await Future.wait( + places.map((p) => writeIndividualEncryptedPlaceFile(p)), + ); + // Delete the legacy aggregate file + await PodFileSystem.deleteFile(getEncryptedPlacesFilePath()); + } + } catch (e) { + debugPrint('Failed to migrate legacy encrypted places: $e'); } } - } catch (e) { - debugPrint('Failed to parse encrypted places JSON: $e'); } places.sort((a, b) => b.timestamp.compareTo(a.timestamp)); @@ -106,6 +155,8 @@ Future> fetchEncryptedPlacesFromPod() async { /// Write encrypted places to Pod. /// Returns (success, dirCreated) tuple. +/// Since granular individual files are written separately, this function +/// only needs to ensure that the encrypted places directory is set up. Future<(bool success, bool dirCreated)> writeEncryptedPlacesToPod( List places, bool directoryVerified, @@ -119,31 +170,6 @@ Future<(bool success, bool dirCreated)> writeEncryptedPlacesToPod( return (false, false); } - // Use relative paths (writePod uses PathType.relativeToData by default) - - final filePath = getEncryptedPlacesFilePath(); - final dirPath = getEncryptedPlacesDirPath(); - - // Convert places to JSON - - final jsonList = places.map((p) => p.toJson()).toList(); - final jsonContent = jsonEncode(jsonList); - - // Write encrypted file with key inheritance from directory - // Note: When using inheritKeyFrom, the encryption is handled by the - // directory's key, not by a file-specific individual key. So we set - // encrypted: false to avoid the "encryption status changed" dialog. - // The file will still be encrypted via the inherited directory key. - - await writePod( - filePath, - jsonContent, - encrypted: false, - overwrite: true, - inheritKeyFrom: dirPath, - ); - - // writePod returns void in 0.9.x, assume success if no exception return (true, dirCreated); } catch (e) { debugPrint('Error writing encrypted places: $e'); diff --git a/lib/services/pod/pod_directory_service.dart b/lib/services/pod/pod_directory_service.dart index eb75dc5..d3a536c 100644 --- a/lib/services/pod/pod_directory_service.dart +++ b/lib/services/pod/pod_directory_service.dart @@ -295,6 +295,7 @@ class PodDirectoryService { // Evict from local cache regardless of server response. _cache.remove(relativePath); invalidateCache(relativePath); + notifyChange(); return containerGone; } catch (e) { diff --git a/lib/widgets/settings/settings_actions.dart b/lib/widgets/settings/settings_actions.dart index 18d1358..bc87077 100644 --- a/lib/widgets/settings/settings_actions.dart +++ b/lib/widgets/settings/settings_actions.dart @@ -82,6 +82,20 @@ Widget buildUserActionsSection(BuildContext context) { style: TextButton.styleFrom(foregroundColor: Colors.red.shade700), ), ), + + const SizedBox(height: 8), + + // DEBUG: Wipe all data in the connected pod (except profile) + Center( + child: TextButton.icon( + onPressed: () => DebugDataService.clearAllPodData(context), + icon: const Icon(Icons.cleaning_services, size: 18), + label: const Text('Clear All Pod Data (DEBUG)'), + style: TextButton.styleFrom( + foregroundColor: Colors.red.shade900, + ), + ), + ), ], ); },