Offline tab as Files view, automatic background sync, sync notifications
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:
2026-09-21 18:06:55 -04:00
co-authored by Claude Sonnet 5
parent 49ae712f5e
commit f2bba2914e
38 changed files with 3133 additions and 741 deletions
+170 -31
View File
@@ -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,
);
}
}