Add device sync: mirror folders/files locally, background WorkManager engine
Build APK / build (push) Successful in 7m33s

- New native sync engine (SyncEngine/SyncWorker/ConflictResolveWorker,
  WorkManager-based) that mirrors selected Nextcloud folders or files to
  app-private local storage, periodically and on-demand ("Sync now"),
  with never-auto-resolved conflict notifications.
- Single files (not just folders) can now be marked "Sync to device".
- Live sync status (syncing/synced/conflicts) pushes from native to Dart
  over a new EventChannel; the persistent header chip/panel now reflects
  device-sync status instead of the WebDAV-refresh loading state, with a
  cloud_off/cloud_sync/cloud_done/cloud_alert icon set and an expandable
  conflicts list with in-app "Keep local"/"Use server" resolution.
  Per-item cloud_done/sync badges show on Files tiles.
- Share/Download short-circuit to the local copy for already-synced
  files instead of a fresh network fetch.
- Settings gained a Device Sync section (per-folder list, "Sync
  everything", Wi-Fi-only toggle, manual "Sync now").
- Fixed two release-only bugs found via on-device testing: Android's
  HttpURLConnection silently rejects the PROPFIND method (switched to
  OkHttp), and R8 was stripping WorkManager's reflection-instantiated
  internals (broadened proguard-rules.pro to keep androidx.work.**
  wholesale rather than chasing individual classes).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 00:52:56 -04:00
co-authored by Claude Sonnet 5
parent 722b66f7b1
commit faaec5365f
21 changed files with 2023 additions and 42 deletions
+93 -17
View File
@@ -1,8 +1,10 @@
import 'package:file_picker/file_picker.dart';
import 'package:file_saver/file_saver.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:path/path.dart' as p;
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import '../models/nextcloud_item.dart';
@@ -21,6 +23,7 @@ import '../widgets/share_sheet.dart';
import '../widgets/sort_menu_button.dart';
import '../widgets/sticky_header_delegate.dart';
import '../widgets/swipeable_item.dart';
import '../widgets/sync_status_badge.dart';
import '../widgets/synced_header_scaffold.dart';
import 'file_viewer_screen.dart';
import 'move_copy_destination_picker.dart';
@@ -284,6 +287,18 @@ class _FilesViewState extends State<FilesView>
label: 'Rename',
onTap: () => _renameItem(provider, selected.single),
),
if (selected.length == 1)
SelectionAction(
icon: provider.isPathSynced(selected.single.path)
? Icons.sync_rounded
: Icons.sync_outlined,
label: provider.isPathSynced(selected.single.path)
? 'Stop syncing to device'
: 'Sync to device',
onTap: () => provider.isPathSynced(selected.single.path)
? provider.removeSyncedPath(selected.single.path)
: provider.addSyncedPath(selected.single.path),
),
if (selected.length == 1)
SelectionAction(
icon: Icons.info_outline_rounded,
@@ -803,6 +818,46 @@ class _FilesViewState extends State<FilesView>
if (files.isEmpty) return;
final messenger = ScaffoldMessenger.of(context);
// Already mirrored locally by device sync (every selected file, not
// just some)? Save straight from the local copy instead of a fresh
// network fetch through DownloadService - see
// ServerProvider.localSyncedFilePath.
final localPaths = await Future.wait(
files.map(provider.localSyncedFilePath),
);
if (localPaths.every((path) => path != null)) {
try {
for (var i = 0; i < files.length; i++) {
final ext = p.extension(files[i].name).replaceFirst('.', '');
final baseName = p.basenameWithoutExtension(files[i].name);
await FileSaver.instance.saveFile(
name: baseName,
filePath: localPaths[i]!,
ext: ext,
);
}
messenger.showSnackBar(
SnackBar(
content: Text(
files.length == 1
? 'Downloaded ${files.first.name}'
: 'Downloaded ${files.length} files',
),
behavior: SnackBarBehavior.floating,
),
);
} catch (e) {
messenger.showSnackBar(
SnackBar(
content: Text('Could not save file(s): $e'),
behavior: SnackBarBehavior.floating,
),
);
}
return;
}
try {
await DownloadService.startDownload(provider, files);
messenger.showSnackBar(
@@ -930,17 +985,29 @@ class _FilesViewState extends State<FilesView>
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
SelectableThumbnail(
isSelected: isSelected,
size: 44,
checkmarkSize: 24,
child: ItemThumbnail(
item: item,
provider: provider,
size: 44,
borderRadius: 12,
iconSize: 22,
),
Stack(
clipBehavior: Clip.none,
children: [
SelectableThumbnail(
isSelected: isSelected,
size: 44,
checkmarkSize: 24,
child: ItemThumbnail(
item: item,
provider: provider,
size: 44,
borderRadius: 12,
iconSize: 22,
),
),
Positioned(
right: -2,
bottom: -2,
child: SyncStatusBadge(
status: provider.syncStatusFor(item),
),
),
],
),
const SizedBox(width: 14),
Expanded(
@@ -1036,12 +1103,21 @@ class _FilesViewState extends State<FilesView>
onLongPress: provider.isPicking && !provider.pickRequest!.allowMultiple
? null
: () => _toggleSelection(item),
child: SelectableThumbnail(
isSelected: isSelected,
checkmarkSize: 32,
child: isMedia
? _buildMediaGridContent(context, item, provider)
: _buildPlainGridContent(context, item),
child: Stack(
children: [
SelectableThumbnail(
isSelected: isSelected,
checkmarkSize: 32,
child: isMedia
? _buildMediaGridContent(context, item, provider)
: _buildPlainGridContent(context, item),
),
Positioned(
right: 6,
bottom: 6,
child: SyncStatusBadge(status: provider.syncStatusFor(item)),
),
],
),
),
);