Skip to content

Commit bd7b118

Browse files
committed
Added delete to collaboration
1 parent 4c80d61 commit bd7b118

4 files changed

Lines changed: 212 additions & 54 deletions

File tree

lib/core/models/collaboration_group.dart

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,9 @@ class CollaborationGroup extends Equatable {
113113
/// Files in this group
114114
final List<CollaborationFile> files;
115115

116+
/// IDs of files that have been removed (tombstones for merge correctness)
117+
final List<String> removedFileIds;
118+
116119
/// Version counter for conflict resolution (incremented on each update)
117120
final int version;
118121

@@ -129,6 +132,7 @@ class CollaborationGroup extends Equatable {
129132
this.expiresAt,
130133
this.isRevoked = false,
131134
required this.files,
135+
this.removedFileIds = const [],
132136
required this.version,
133137
required this.updatedAt,
134138
});
@@ -152,6 +156,7 @@ class CollaborationGroup extends Equatable {
152156
DateTime? expiresAt,
153157
bool? isRevoked,
154158
List<CollaborationFile>? files,
159+
List<String>? removedFileIds,
155160
int? version,
156161
DateTime? updatedAt,
157162
}) =>
@@ -165,23 +170,33 @@ class CollaborationGroup extends Equatable {
165170
expiresAt: expiresAt ?? this.expiresAt,
166171
isRevoked: isRevoked ?? this.isRevoked,
167172
files: files ?? this.files,
173+
removedFileIds: removedFileIds ?? this.removedFileIds,
168174
version: version ?? this.version,
169175
updatedAt: updatedAt ?? this.updatedAt,
170176
);
171177

172-
/// Merge file lists from two versions (union by file ID)
178+
/// Merge file lists from two versions.
179+
/// Uses tombstone-based deletion: files in [removedFileIds] from either
180+
/// side are excluded from the merged result.
173181
CollaborationGroup mergeWith(CollaborationGroup other) {
182+
// Union tombstones from both sides
183+
final mergedTombstones = <String>{...removedFileIds, ...other.removedFileIds};
184+
185+
// Union files by ID, then filter out tombstoned files
174186
final mergedFiles = <String, CollaborationFile>{};
175187
for (final f in files) {
176188
mergedFiles[f.id] = f;
177189
}
178190
for (final f in other.files) {
179191
mergedFiles.putIfAbsent(f.id, () => f);
180192
}
193+
mergedFiles.removeWhere((id, _) => mergedTombstones.contains(id));
194+
181195
final higherVersion = version >= other.version ? this : other;
182196
return higherVersion.copyWith(
183197
files: mergedFiles.values.toList()
184198
..sort((a, b) => a.addedAt.compareTo(b.addedAt)),
199+
removedFileIds: mergedTombstones.toList(),
185200
version: (version > other.version ? version : other.version) + 1,
186201
updatedAt: DateTime.now(),
187202
);
@@ -197,6 +212,7 @@ class CollaborationGroup extends Equatable {
197212
if (expiresAt != null) 'expiresAt': expiresAt!.toIso8601String(),
198213
'isRevoked': isRevoked,
199214
'files': files.map((f) => f.toJson()).toList(),
215+
if (removedFileIds.isNotEmpty) 'removedFileIds': removedFileIds,
200216
'version': version,
201217
'updatedAt': updatedAt.toIso8601String(),
202218
};
@@ -218,12 +234,16 @@ class CollaborationGroup extends Equatable {
218234
CollaborationFile.fromJson(f as Map<String, dynamic>))
219235
.toList() ??
220236
[],
237+
removedFileIds: (json['removedFileIds'] as List<dynamic>?)
238+
?.map((e) => e as String)
239+
.toList() ??
240+
[],
221241
version: json['version'] as int? ?? 1,
222242
updatedAt: DateTime.parse(json['updatedAt'] as String),
223243
);
224244

225245
@override
226-
List<Object?> get props => [id, name, ownerPublicKey, version];
246+
List<Object?> get props => [id, name, ownerPublicKey, version, removedFileIds];
227247
}
228248

229249
/// A collaboration group created by this user (outgoing)

lib/core/services/collab_folder_sync_service.dart

Lines changed: 123 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -192,72 +192,91 @@ class CollabFolderSyncService {
192192
final folderPath = info.folderPath!;
193193
final group = info.group;
194194
final syncMeta = await _readSyncMeta(folderPath);
195+
bool hasChanges = false;
195196

196-
// Find files not yet downloaded
197+
// --- Download new files ---
197198
final filesToDownload = group.files.where((f) {
198-
if (f.contentType == 'application/x-directory') return false; // skip folder markers
199+
if (f.contentType == 'application/x-directory') return false;
199200
return !syncMeta.containsKey(f.id);
200201
}).toList();
201202

202-
if (filesToDownload.isEmpty) {
203-
// No changes — back off poll interval (double, up to max)
204-
final current = _currentPollInterval[groupId] ?? _minPollInterval;
205-
if (current < _maxPollInterval) {
206-
_currentPollInterval[groupId] = Duration(
207-
seconds: (current.inSeconds * 2).clamp(
208-
_minPollInterval.inSeconds,
209-
_maxPollInterval.inSeconds,
210-
),
211-
);
212-
}
213-
return;
214-
}
215-
216-
// Changes found — reset to fast polling
217-
_currentPollInterval[groupId] = _minPollInterval;
203+
if (filesToDownload.isNotEmpty) {
204+
hasChanges = true;
205+
debugPrint('[CollabFolderSync] Downloading ${filesToDownload.length} files for $groupId');
218206

219-
debugPrint('[CollabFolderSync] Downloading ${filesToDownload.length} files for $groupId');
207+
for (final file in filesToDownload) {
208+
try {
209+
final data = await CollaborationService.instance.downloadCollabFile(groupId, file);
210+
final localPath = _resolveLocalPath(folderPath, file);
211+
final localFile = File(localPath);
212+
await localFile.parent.create(recursive: true);
213+
await localFile.writeAsBytes(data);
220214

221-
for (final file in filesToDownload) {
222-
try {
223-
final data = await CollaborationService.instance.downloadCollabFile(groupId, file);
224-
225-
// Determine local file path (handle pathScope subfolders)
226-
final localPath = _resolveLocalPath(folderPath, file);
227-
final localFile = File(localPath);
228-
229-
// Ensure parent directory exists
230-
await localFile.parent.create(recursive: true);
231-
await localFile.writeAsBytes(data);
232-
233-
// Track in sync metadata
234-
syncMeta[file.id] = _SyncedFileEntry(
235-
fileName: file.fileName,
236-
direction: 'download',
237-
syncedAt: DateTime.now(),
238-
);
239-
240-
debugPrint('[CollabFolderSync] Downloaded: ${file.fileName}');
241-
} catch (e) {
242-
debugPrint('[CollabFolderSync] Download failed (${file.fileName}): $e');
243-
// Track 404 failures to avoid retrying missing files every poll
244-
final errStr = e.toString();
245-
if (errStr.contains('404')) {
246215
syncMeta[file.id] = _SyncedFileEntry(
247216
fileName: file.fileName,
248-
direction: 'download_failed',
217+
direction: 'download',
249218
syncedAt: DateTime.now(),
250219
);
220+
debugPrint('[CollabFolderSync] Downloaded: ${file.fileName}');
221+
} catch (e) {
222+
debugPrint('[CollabFolderSync] Download failed (${file.fileName}): $e');
223+
final errStr = e.toString();
224+
if (errStr.contains('404')) {
225+
syncMeta[file.id] = _SyncedFileEntry(
226+
fileName: file.fileName,
227+
direction: 'download_failed',
228+
syncedAt: DateTime.now(),
229+
);
230+
}
251231
}
252232
}
253233
}
254234

255-
await _writeSyncMeta(folderPath, groupId, syncMeta);
235+
// --- Propagate remote deletions (tombstones) to local ---
236+
final removedIds = group.removedFileIds.toSet();
237+
if (removedIds.isNotEmpty) {
238+
final entriesToRemove = <String>[];
239+
for (final entry in syncMeta.entries) {
240+
if (entry.value.direction == 'download_failed') continue;
241+
if (removedIds.contains(entry.key)) {
242+
final localPath = '$folderPath${Platform.pathSeparator}${entry.value.fileName}';
243+
final localFile = File(localPath);
244+
if (await localFile.exists()) {
245+
try {
246+
await localFile.delete();
247+
debugPrint('[CollabFolderSync] Deleted local file (remote removal): ${entry.value.fileName}');
248+
hasChanges = true;
249+
} catch (e) {
250+
debugPrint('[CollabFolderSync] Failed to delete local file: $localPath: $e');
251+
}
252+
}
253+
entriesToRemove.add(entry.key);
254+
}
255+
}
256+
for (final id in entriesToRemove) {
257+
syncMeta.remove(id);
258+
}
259+
}
260+
261+
// --- Adjust poll interval ---
262+
if (hasChanges) {
263+
_currentPollInterval[groupId] = _minPollInterval;
264+
await _writeSyncMeta(folderPath, groupId, syncMeta);
265+
} else {
266+
final current = _currentPollInterval[groupId] ?? _minPollInterval;
267+
if (current < _maxPollInterval) {
268+
_currentPollInterval[groupId] = Duration(
269+
seconds: (current.inSeconds * 2).clamp(
270+
_minPollInterval.inSeconds,
271+
_maxPollInterval.inSeconds,
272+
),
273+
);
274+
}
275+
}
256276
} catch (e) {
257277
debugPrint('[CollabFolderSync] Poll error ($groupId): $e');
258278
} finally {
259279
_syncing[groupId] = false;
260-
// Reschedule next poll (only if sync is still active for this group)
261280
if (_watchers.containsKey(groupId)) {
262281
_schedulePoll(groupId);
263282
}
@@ -289,16 +308,25 @@ class CollabFolderSyncService {
289308
}
290309

291310
void _handleLocalFileChange(String groupId, FileSystemEvent event) {
292-
// Only handle file creation/modification
293-
if (event is! FileSystemCreateEvent && event is! FileSystemModifyEvent) return;
311+
// Handle file creation, modification, and deletion
312+
if (event is! FileSystemCreateEvent &&
313+
event is! FileSystemModifyEvent &&
314+
event is! FileSystemDeleteEvent) return;
294315

295316
final path = event.path;
296317
final fileName = path.split(Platform.pathSeparator).last;
297318

298319
// Skip sync metadata and hidden files
299320
if (fileName == _syncMetaFile || fileName.startsWith('.')) return;
300321

301-
// Skip directories
322+
// Handle deletions immediately (file no longer exists on disk)
323+
if (event is FileSystemDeleteEvent) {
324+
debugPrint('[CollabFolderSync] File deletion detected: $fileName for group $groupId');
325+
_handleLocalFileDeletion(groupId, path);
326+
return;
327+
}
328+
329+
// Skip directories (only for create/modify — deleted paths can't be checked)
302330
if (FileSystemEntity.isDirectorySync(path)) return;
303331

304332
debugPrint('[CollabFolderSync] File change detected: $fileName (${event.runtimeType}) for group $groupId');
@@ -311,6 +339,51 @@ class CollabFolderSyncService {
311339
});
312340
}
313341

342+
/// Handle deletion of a local file: find its collab ID from sync metadata,
343+
/// remove from the collaboration manifest, and clean up sync metadata.
344+
Future<void> _handleLocalFileDeletion(String groupId, String filePath) async {
345+
try {
346+
final info = await _getGroupInfo(groupId);
347+
if (info == null || info.folderPath == null) return;
348+
349+
final folderPath = info.folderPath!;
350+
final fileName = filePath.split(Platform.pathSeparator).last;
351+
final syncMeta = await _readSyncMeta(folderPath);
352+
353+
// Find the file ID by matching fileName in sync metadata
354+
String? matchedFileId;
355+
for (final entry in syncMeta.entries) {
356+
if (entry.value.fileName == fileName &&
357+
(entry.value.direction == 'upload' || entry.value.direction == 'download')) {
358+
matchedFileId = entry.key;
359+
break;
360+
}
361+
}
362+
363+
if (matchedFileId == null) {
364+
debugPrint('[CollabFolderSync] Deleted file not found in sync metadata: $fileName');
365+
return;
366+
}
367+
368+
debugPrint('[CollabFolderSync] Propagating deletion: $fileName (fileId=$matchedFileId)');
369+
_emitStatus(groupId, CollabSyncState.syncing);
370+
371+
await CollaborationService.instance.removeFileFromGroup(
372+
groupId: groupId,
373+
fileId: matchedFileId,
374+
);
375+
376+
syncMeta.remove(matchedFileId);
377+
await _writeSyncMeta(folderPath, groupId, syncMeta);
378+
379+
debugPrint('[CollabFolderSync] Deletion propagated: $fileName');
380+
_emitStatus(groupId, CollabSyncState.synced);
381+
} catch (e) {
382+
debugPrint('[CollabFolderSync] Deletion propagation error ($filePath): $e');
383+
_emitStatus(groupId, CollabSyncState.error);
384+
}
385+
}
386+
314387
Future<void> _uploadLocalFileIfNew(String groupId, String filePath) async {
315388
try {
316389
final info = await _getGroupInfo(groupId);

lib/core/services/collaboration_service.dart

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -998,6 +998,71 @@ class CollaborationService {
998998
}
999999
}
10001000

1001+
/// Remove a file from a collaboration group by adding it to the tombstone list.
1002+
///
1003+
/// Updates the manifest in S3 and server DB. The file entry is removed
1004+
/// from [files] and its ID is added to [removedFileIds] so that
1005+
/// [mergeWith] correctly suppresses it across all manifest sources.
1006+
Future<void> removeFileFromGroup({
1007+
required String groupId,
1008+
required String fileId,
1009+
bool deleteFromStorage = true,
1010+
}) async {
1011+
final outgoing = await _findOutgoingCollab(groupId);
1012+
final accepted = await _findAcceptedCollab(groupId);
1013+
CollaborationGroup? group;
1014+
Uint8List? linkSecretKey;
1015+
1016+
if (outgoing != null) {
1017+
group = outgoing.group;
1018+
linkSecretKey = outgoing.linkSecretKey;
1019+
} else if (accepted != null) {
1020+
group = accepted.group;
1021+
linkSecretKey = accepted.linkSecretKey;
1022+
}
1023+
1024+
if (group == null) {
1025+
throw CollaborationException('Group not found: $groupId');
1026+
}
1027+
1028+
final updatedFiles = group.files.where((f) => f.id != fileId).toList();
1029+
final updatedTombstones = [...group.removedFileIds, fileId];
1030+
1031+
final updatedGroup = group.copyWith(
1032+
files: updatedFiles,
1033+
removedFileIds: updatedTombstones,
1034+
version: group.version + 1,
1035+
updatedAt: DateTime.now(),
1036+
);
1037+
1038+
await _uploadManifest(updatedGroup, linkSecretKey: linkSecretKey);
1039+
1040+
if (outgoing != null) {
1041+
await _updateOutgoingCollaboration(outgoing.copyWith(group: updatedGroup));
1042+
} else if (accepted != null) {
1043+
await _updateAcceptedCollaboration(accepted.copyWith(group: updatedGroup));
1044+
}
1045+
1046+
// Clean up encrypted file blob from S3 (non-fatal on failure)
1047+
if (deleteFromStorage) {
1048+
try {
1049+
final jwt = await SecureStorageService.instance.read(SecureStorageKeys.jwtToken);
1050+
final url = '$kCollabGatewayBaseUrl/api/collab/$groupId/file/$fileId';
1051+
await http.delete(
1052+
Uri.parse(url),
1053+
headers: {
1054+
if (jwt != null && jwt.isNotEmpty) 'Authorization': 'Bearer $jwt',
1055+
},
1056+
);
1057+
} catch (e) {
1058+
debugPrint('[CollabService] S3 file deletion failed (non-fatal): $e');
1059+
}
1060+
}
1061+
1062+
debugPrint('[CollabService] Removed file $fileId from group $groupId');
1063+
}
1064+
}
1065+
10011066
/// Input data for a file being added to a collaboration group
10021067
class CollabFileInput {
10031068
final String fileName;

pubspec.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name: fula_files
22
description: FxFiles - A minimalistic file manager with Fula decentralized storage backup support.
33
publish_to: 'none'
4-
version: 1.7.0+528
4+
version: 1.7.1+529
55

66
environment:
77
sdk: '>=3.2.0 <4.0.0'
@@ -149,7 +149,7 @@ msix_config:
149149
publisher_display_name: Functionland
150150
publisher: CN=E9FEC2DC-DBBE-45BA-A112-26EFEA253DB5
151151
identity_name: Functionland.FxFiles
152-
msix_version: 1.7.0.0
152+
msix_version: 1.7.1.0
153153
logo_path: assets/icons/icon.png
154154
capabilities: internetClient, internetClientServer, unvirtualizedResources
155155
protocol_activation: fxfiles

0 commit comments

Comments
 (0)