Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 177 additions & 1 deletion lib/services/debug_data_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -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<void> clearAllPodData(BuildContext context) async {
final confirmed = await showDialog<bool>(
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<void>(builder: (_) => const App()),
(_) => false,
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error clearing pod data: ${result.error}'),
backgroundColor: Colors.red,
),
);
}
}

static Future<DeleteAllResult> _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<void> _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';
}
}
8 changes: 8 additions & 0 deletions lib/services/media/media_pod_service_index.dart
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,14 @@ Future<bool> _writeIndex(MediaType type, List<MediaItem> 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<MediaItem>.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');
Expand Down
112 changes: 69 additions & 43 deletions lib/services/places/encrypted_places_io.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand 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<List<Place>> fetchEncryptedPlacesFromPod() async {
final places = <Place>[];

Expand All @@ -64,24 +65,38 @@ Future<List<Place>> 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 = <Future<String?>>[];
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<String, dynamic>) {
final place = Place.fromJson(
item,
Expand All @@ -90,10 +105,44 @@ Future<List<Place>> 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<String, dynamic>) {
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));
Expand All @@ -106,6 +155,8 @@ Future<List<Place>> 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<Place> places,
bool directoryVerified,
Expand All @@ -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');
Expand Down
1 change: 1 addition & 0 deletions lib/services/pod/pod_directory_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@ class PodDirectoryService {
// Evict from local cache regardless of server response.
_cache.remove(relativePath);
invalidateCache(relativePath);
notifyChange();

return containerGone;
} catch (e) {
Expand Down
14 changes: 14 additions & 0 deletions lib/widgets/settings/settings_actions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
),
),
],
);
},
Expand Down