Offline tab as Files view, automatic background sync, sync notifications
Build APK / build (push) Successful in 5m27s
Build APK / build (push) Successful in 5m27s
- Offline tab is now FilesView(offline: true) over a FolderBrowser interface (FilesController / OfflineController) sharing controls, tiles, thumbnails - Synced files follow the Files Cache rule (default: refresh every 15 min): per-account WorkManager jobs, in-app timer, resume and pull triggers; root-etag shortcut skips full walks when nothing changed - Single sync at a time (shared lock); logging out stops that account's sync - Sync safety: PROPFIND failures skip the path instead of deleting local files - Mirror empty folders and remove deleted ones; drop synced paths deleted on the server; missing local files are re-downloaded - Notifications: silent per-account sync notifications, "Background sync notifications" setting, audible upload/download completion - Files refresh in place (no spinner flash); retry after network returns - Fix sync badges not updating (shared native status stream), "Sync off" on launch (eager providers), Files Cache section moved under Device Sync Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+170
-31
@@ -1,9 +1,13 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import '../models/sync_status.dart';
|
||||
import '../models/saved_account.dart';
|
||||
import '../providers/files_controller.dart';
|
||||
import '../providers/session_controller.dart';
|
||||
import '../providers/sync_status_controller.dart';
|
||||
|
||||
@@ -16,12 +20,17 @@ class SyncStatusSnapshot {
|
||||
final Set<String> syncedFileIds;
|
||||
final List<SyncConflictInfo> conflicts;
|
||||
|
||||
/// Configured sync paths the server says no longer exist (deleted on the
|
||||
/// web) - the app drops them from its synced list.
|
||||
final Set<String> missingRoots;
|
||||
|
||||
const SyncStatusSnapshot({
|
||||
required this.accountId,
|
||||
required this.syncing,
|
||||
required this.syncingFileIds,
|
||||
required this.syncedFileIds,
|
||||
required this.conflicts,
|
||||
this.missingRoots = const {},
|
||||
});
|
||||
|
||||
factory SyncStatusSnapshot.fromMap(Map<dynamic, dynamic> map) {
|
||||
@@ -38,6 +47,9 @@ class SyncStatusSnapshot {
|
||||
.cast<Map<dynamic, dynamic>>()
|
||||
.map(SyncConflictInfo.fromMap)
|
||||
.toList(),
|
||||
missingRoots: ((map['missingRoots'] as List?) ?? const [])
|
||||
.cast<String>()
|
||||
.toSet(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -56,42 +68,89 @@ class SyncService {
|
||||
'dev.ayushya.noo/sync_service/status',
|
||||
);
|
||||
|
||||
/// Cancels (if [SyncStatusController.syncedPaths] is now empty) or
|
||||
/// re-enqueues the periodic sync job with fresh account/credentials/
|
||||
/// path-list/network-constraint data - call this any time one of those
|
||||
/// changes, since a periodic `WorkRequest`'s input is fixed at enqueue
|
||||
/// time and can only be updated by re-enqueueing.
|
||||
/// Brings the background periodic sync jobs in line with every saved
|
||||
/// account's current settings - one job per account with sync enabled
|
||||
/// (paths or "sync everything", and the Files Cache policy set to refresh
|
||||
/// periodically), cancelled for accounts without and for accounts the
|
||||
/// user logged out of. Background sync is deliberately not limited to the
|
||||
/// active account: each account's job carries that account's own
|
||||
/// credentials, paths and interval, so switching accounts doesn't stop the
|
||||
/// others.
|
||||
///
|
||||
/// Call whenever any of that changes - a periodic `WorkRequest`'s input is
|
||||
/// fixed at enqueue time and can only be updated by re-enqueueing. The
|
||||
/// active account's settings come from [sync]/its live state (they may not
|
||||
/// have been persisted yet); every other account's are read from storage.
|
||||
static Future<void> reschedule(
|
||||
SessionController session,
|
||||
SyncStatusController sync,
|
||||
) async {
|
||||
final accountId = session.activeAccountId;
|
||||
final authHeader = session.service?.authHeaders['Authorization'];
|
||||
if (accountId == null || authHeader == null) {
|
||||
return cancel();
|
||||
final prefs = await session.prefsFuture;
|
||||
final store = session.accountStore;
|
||||
final wifiOnly = !sync.syncOnCellular;
|
||||
final signedOut = store.loadSignedOut(prefs);
|
||||
|
||||
for (final account in session.accounts) {
|
||||
try {
|
||||
if (signedOut.contains(account.id)) {
|
||||
await cancelAccount(account.id);
|
||||
continue;
|
||||
}
|
||||
final isActive = account.id == session.activeAccountId;
|
||||
final config = isActive
|
||||
? _SyncConfig(
|
||||
paths: sync.syncEverything ? ['/'] : sync.syncedPaths,
|
||||
intervalMinutes: sync.backgroundSyncIntervalMinutes,
|
||||
)
|
||||
: _SyncConfig.fromPrefs(prefs, store.accountPrefKey, account.id);
|
||||
final password = await store.readPassword(account.id);
|
||||
if (password == null ||
|
||||
config.paths.isEmpty ||
|
||||
config.intervalMinutes == null) {
|
||||
await cancelAccount(account.id);
|
||||
continue;
|
||||
}
|
||||
await _channel.invokeMethod('reschedule', {
|
||||
'accountId': account.id,
|
||||
'serverUrl': account.serverUrl,
|
||||
'username': account.username,
|
||||
'authHeader': _basicAuth(account, password),
|
||||
'folders': jsonEncode(config.paths),
|
||||
'wifiOnly': wifiOnly,
|
||||
'intervalMinutes': config.intervalMinutes,
|
||||
'notify': sync.syncNotifications,
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('[SyncService] Could not schedule ${account.id}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
final paths = sync.syncEverything ? ['/'] : sync.syncedPaths;
|
||||
await _channel.invokeMethod('reschedule', {
|
||||
'accountId': accountId,
|
||||
'serverUrl': session.serverUrl,
|
||||
'username': session.username,
|
||||
'authHeader': authHeader,
|
||||
'folders': jsonEncode(paths),
|
||||
'wifiOnly': !sync.syncOnCellular,
|
||||
});
|
||||
}
|
||||
|
||||
static Future<void> cancel() async {
|
||||
await _channel.invokeMethod('cancel');
|
||||
static String _basicAuth(SavedAccount account, String password) =>
|
||||
'Basic ${base64Encode(utf8.encode('${account.username}:$password'))}';
|
||||
|
||||
/// Stops [accountId]'s periodic job (the account was removed, or has
|
||||
/// nothing left to sync).
|
||||
static Future<void> cancelAccount(String accountId) async {
|
||||
await _channel.invokeMethod('cancel', {'accountId': accountId});
|
||||
}
|
||||
|
||||
/// Runs a one-off sync pass immediately (Settings' "Sync now"),
|
||||
/// independent of the periodic schedule.
|
||||
/// Runs a one-off sync pass immediately, independent of the periodic
|
||||
/// schedule.
|
||||
///
|
||||
/// [force] (Settings' "Sync now", a newly-added path): full walk of every
|
||||
/// synced root, visible progress notification, supersedes any run already
|
||||
/// in flight. Without it (pull-to-refresh, foreground timer, app resume)
|
||||
/// the pass is a quiet check that uses the cheap root-etag shortcut and
|
||||
/// never interrupts a run already in progress. [respectNetworkSetting]
|
||||
/// makes such a pass wait for Wi-Fi when "Sync on cellular" is off (the
|
||||
/// automatic triggers do; a deliberate pull doesn't).
|
||||
static Future<void> syncNow(
|
||||
SessionController session,
|
||||
SyncStatusController sync,
|
||||
) async {
|
||||
SyncStatusController sync, {
|
||||
bool force = true,
|
||||
bool respectNetworkSetting = false,
|
||||
}) async {
|
||||
final accountId = session.activeAccountId;
|
||||
final authHeader = session.service?.authHeaders['Authorization'];
|
||||
if (accountId == null || authHeader == null) {
|
||||
@@ -105,26 +164,43 @@ class SyncService {
|
||||
'username': session.username,
|
||||
'authHeader': authHeader,
|
||||
'folders': jsonEncode(paths),
|
||||
'force': force,
|
||||
'notify': sync.syncNotifications,
|
||||
if (respectNetworkSetting) 'wifiOnly': !sync.syncOnCellular,
|
||||
});
|
||||
}
|
||||
|
||||
/// One-shot status snapshot - used to seed [SyncStatusController]'s state
|
||||
/// right after login/account-switch, before the first [statusStream]
|
||||
/// event arrives.
|
||||
static Future<SyncStatusSnapshot> getStatus() async {
|
||||
///
|
||||
/// [accountId] is required for `syncedFileIds` to be populated on a fresh
|
||||
/// app start - the native bus only knows an account once a sync pass has
|
||||
/// run in this process.
|
||||
static Future<SyncStatusSnapshot> getStatus(String? accountId) async {
|
||||
final result = await _channel.invokeMethod<Map<dynamic, dynamic>>(
|
||||
'getSyncStatus',
|
||||
{'accountId': accountId},
|
||||
);
|
||||
return SyncStatusSnapshot.fromMap(result ?? const {});
|
||||
}
|
||||
|
||||
/// Live status updates pushed from `SyncStatusBus` (Kotlin) - syncing
|
||||
/// started/stopped, per-file progress, new/resolved conflicts.
|
||||
static Stream<SyncStatusSnapshot> get statusStream {
|
||||
return _statusChannel.receiveBroadcastStream().map(
|
||||
(event) => SyncStatusSnapshot.fromMap(event as Map<dynamic, dynamic>),
|
||||
);
|
||||
}
|
||||
///
|
||||
/// One shared stream, deliberately: every call to
|
||||
/// `EventChannel.receiveBroadcastStream()` opens its *own* native
|
||||
/// subscription, and the native side only keeps one listener - a second
|
||||
/// subscriber (the Offline tab's controller, once it became eagerly
|
||||
/// created) silently took the events away from the first
|
||||
/// (`SyncStatusController`), so sync badges stopped updating until the app
|
||||
/// was restarted. This is a broadcast stream, so any number of Dart
|
||||
/// listeners share the one native subscription.
|
||||
static final Stream<SyncStatusSnapshot> statusStream = _statusChannel
|
||||
.receiveBroadcastStream()
|
||||
.map(
|
||||
(event) => SyncStatusSnapshot.fromMap(event as Map<dynamic, dynamic>),
|
||||
);
|
||||
|
||||
/// In-app conflict resolution (the header's "Keep local"/"Use server"
|
||||
/// buttons) - shares the exact same native `ConflictResolveWorker`
|
||||
@@ -184,3 +260,66 @@ class SyncService {
|
||||
return file.existsSync() ? file.path : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// One account's background-sync settings, as far as scheduling is
|
||||
/// concerned.
|
||||
class _SyncConfig {
|
||||
final List<String> paths;
|
||||
|
||||
/// Null = background sync off for this account.
|
||||
final int? intervalMinutes;
|
||||
|
||||
const _SyncConfig({required this.paths, required this.intervalMinutes});
|
||||
|
||||
/// Reads a *non-active* account's persisted settings - the same per-account
|
||||
/// keys `SyncStatusController`/`FilesController` write. Malformed values
|
||||
/// fall back to "nothing to sync" rather than throwing.
|
||||
factory _SyncConfig.fromPrefs(
|
||||
SharedPreferences prefs,
|
||||
String Function(String accountId, String baseKey) key,
|
||||
String accountId,
|
||||
) {
|
||||
var paths = <String>[];
|
||||
try {
|
||||
final everything =
|
||||
prefs.getBool(
|
||||
key(accountId, SyncStatusController.prefSyncEverything),
|
||||
) ??
|
||||
false;
|
||||
final json = prefs.getString(
|
||||
key(accountId, SyncStatusController.prefSyncedPaths),
|
||||
);
|
||||
if (everything) {
|
||||
paths = ['/'];
|
||||
} else if (json != null) {
|
||||
paths = (jsonDecode(json) as List).cast<String>();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[SyncService] Unreadable sync paths for $accountId: $e');
|
||||
}
|
||||
|
||||
var policy = defaultCachePolicy;
|
||||
var minutes = defaultCacheIntervalMinutes;
|
||||
try {
|
||||
final policyName = prefs.getString(
|
||||
key(accountId, FilesController.prefCachePolicy),
|
||||
);
|
||||
policy = CachePolicy.values.firstWhere(
|
||||
(c) => c.name == policyName,
|
||||
orElse: () => defaultCachePolicy,
|
||||
);
|
||||
minutes =
|
||||
prefs.getInt(
|
||||
key(accountId, FilesController.prefCacheIntervalMinutes),
|
||||
) ??
|
||||
defaultCacheIntervalMinutes;
|
||||
} catch (e) {
|
||||
debugPrint('[SyncService] Unreadable cache rule for $accountId: $e');
|
||||
}
|
||||
|
||||
return _SyncConfig(
|
||||
paths: paths,
|
||||
intervalMinutes: policy == CachePolicy.interval ? minutes : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user