Dedupe native transfer/notification code and split ServerProvider into focused controllers
Build APK / build (push) Successful in 5m20s

Consolidates duplicated GET/PUT/notification-channel logic across
DownloadService/ShareUploadService/SyncEngine into shared Kotlin helpers,
gives upload/download real batch queueing instead of dropping a second
concurrent batch, and dedupes repeated Dart channel-argument boilerplate.

Replaces the 2300+ line ServerProvider god object with ten focused
ChangeNotifiers (SessionController, SettingsController, FilesController,
PhotosController, FavoritesController, TrashController, SharesController,
RecentController, SyncStatusController, PickController) plus ItemOperations,
a plain coordinator for cross-domain item mutations - fixing the coupling
where device-sync status, per-tab data, and global UI prefs all lived in
one object. Updates every view/widget call site accordingly and refreshes
the architecture/server/standards/styling docs to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 10:04:12 -04:00
co-authored by Claude Sonnet 5
parent faaec5365f
commit ff5f04e9a1
58 changed files with 4158 additions and 3594 deletions
+5 -14
View File
@@ -1,7 +1,8 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import '../models/nextcloud_item.dart';
import '../providers/server_provider.dart';
import '../providers/session_controller.dart';
import 'native_channel.dart';
/// Hands a download off to `DownloadService.kt`'s Android foreground
/// service - the download counterpart of `UploadService`/
@@ -20,15 +21,10 @@ class DownloadService {
/// progress/completion/cancellation through its own notification, not
/// back to the app.
static Future<void> startDownload(
ServerProvider provider,
SessionController session,
List<NextcloudItem> items,
) async {
final service = provider.service;
final authHeader = service?.authHeaders['Authorization'];
if (service == null || authHeader == null) {
throw Exception('Not logged in.');
}
final args = baseChannelArgs(session);
final filesJson = jsonEncode(
items
.map(
@@ -42,11 +38,6 @@ class DownloadService {
.toList(),
);
await _channel.invokeMethod('startDownload', {
'files': filesJson,
'serverUrl': provider.serverUrl,
'username': provider.username,
'authHeader': authHeader,
});
await _channel.invokeMethod('startDownload', {...args, 'files': filesJson});
}
}
+23
View File
@@ -0,0 +1,23 @@
import '../providers/session_controller.dart';
/// The "get this account's auth header, or bail" guard every native-
/// MethodChannel service (`UploadService`/`DownloadService`/`SyncService`)
/// needs before handing a batch off to Kotlin - was independently repeated
/// in each of them.
String requireAuthHeader(SessionController session) {
final authHeader = session.service?.authHeaders['Authorization'];
if (session.service == null || authHeader == null) {
throw Exception('Not logged in.');
}
return authHeader;
}
/// The `{serverUrl, username, authHeader}` trio every one of those
/// services' `invokeMethod` argument maps includes.
Map<String, String> baseChannelArgs(SessionController session) {
return {
'serverUrl': session.serverUrl,
'username': session.username,
'authHeader': requireAuthHeader(session),
};
}
+60 -35
View File
@@ -1,7 +1,11 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../models/sync_status.dart';
import '../providers/server_provider.dart';
import '../providers/session_controller.dart';
import '../providers/sync_status_controller.dart';
/// A snapshot of native device-sync status - see `SyncStatusBus.kt` (the
/// in-process pub/sub it mirrors) and `MainActivity.kt`'s `syncStatusMap`.
@@ -43,34 +47,38 @@ class SyncStatusSnapshot {
/// Dart's job here is only to gather what the native side needs, push a
/// fresh config whenever it changes, and listen for live status; the actual
/// PROPFIND-walk/diff/GET/PUT engine runs entirely in Kotlin, independent
/// of the Flutter engine.
/// of the Flutter engine. Takes [SessionController]/[SyncStatusController]
/// directly rather than a single god-object provider, since those are the
/// only two domains this ever needs.
class SyncService {
static const _channel = MethodChannel('dev.ayushya.noo/sync_service');
static const _statusChannel = EventChannel(
'dev.ayushya.noo/sync_service/status',
);
/// Cancels (if [ServerProvider.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.
static Future<void> reschedule(ServerProvider provider) async {
final accountId = provider.activeAccountId;
final service = provider.service;
final authHeader = service?.authHeaders['Authorization'];
if (accountId == null || service == null || authHeader == null) {
/// 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.
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 paths = provider.syncEverything ? ['/'] : provider.syncedPaths;
final paths = sync.syncEverything ? ['/'] : sync.syncedPaths;
await _channel.invokeMethod('reschedule', {
'accountId': accountId,
'serverUrl': provider.serverUrl,
'username': provider.username,
'serverUrl': session.serverUrl,
'username': session.username,
'authHeader': authHeader,
'folders': jsonEncode(paths),
'wifiOnly': !provider.syncOnCellular,
'wifiOnly': !sync.syncOnCellular,
});
}
@@ -80,27 +88,29 @@ class SyncService {
/// Runs a one-off sync pass immediately (Settings' "Sync now"),
/// independent of the periodic schedule.
static Future<void> syncNow(ServerProvider provider) async {
final accountId = provider.activeAccountId;
final service = provider.service;
final authHeader = service?.authHeaders['Authorization'];
if (accountId == null || service == null || authHeader == null) {
static Future<void> syncNow(
SessionController session,
SyncStatusController sync,
) async {
final accountId = session.activeAccountId;
final authHeader = session.service?.authHeaders['Authorization'];
if (accountId == null || authHeader == null) {
throw Exception('Not logged in.');
}
final paths = provider.syncEverything ? ['/'] : provider.syncedPaths;
final paths = sync.syncEverything ? ['/'] : sync.syncedPaths;
await _channel.invokeMethod('syncNow', {
'accountId': accountId,
'serverUrl': provider.serverUrl,
'username': provider.username,
'serverUrl': session.serverUrl,
'username': session.username,
'authHeader': authHeader,
'folders': jsonEncode(paths),
});
}
/// One-shot status snapshot - used to seed [ServerProvider]'s state right
/// after login/account-switch, before the first [statusStream] event
/// arrives.
/// 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 {
final result = await _channel.invokeMethod<Map<dynamic, dynamic>>(
'getSyncStatus',
@@ -121,19 +131,16 @@ class SyncService {
/// enqueue path the notification actions use, just triggered from Dart
/// instead of a `PendingIntent`.
static Future<void> resolveConflict(
ServerProvider provider,
SessionController session,
SyncConflictInfo conflict,
String resolution,
) async {
final service = provider.service;
final authHeader = service?.authHeaders['Authorization'];
if (service == null || authHeader == null) {
throw Exception('Not logged in.');
}
final authHeader = session.service?.authHeaders['Authorization'];
if (authHeader == null) throw Exception('Not logged in.');
await _channel.invokeMethod('resolveConflict', {
'accountId': conflict.accountId,
'serverUrl': provider.serverUrl,
'username': provider.username,
'serverUrl': session.serverUrl,
'username': session.username,
'authHeader': authHeader,
'fileId': conflict.fileId,
'remotePath': conflict.remotePath,
@@ -141,4 +148,22 @@ class SyncService {
'resolution': resolution,
});
}
/// The deterministic local mirror path for [remoteItemPath] under
/// account [accountId] - mirrors `SyncEngine.kt#syncRoot`'s
/// `<externalFilesDir>/sync/<accountId>/...` layout exactly, so this
/// never needs to read native sync state. Returns null if nothing's
/// actually been synced there yet.
static Future<String?> localSyncedFilePath(
String accountId,
String remoteItemPath,
) async {
final base = await getExternalStorageDirectory();
if (base == null) return null;
final relPath = remoteItemPath.startsWith('/')
? remoteItemPath.substring(1)
: remoteItemPath;
final file = File(p.join(base.path, 'sync', accountId, relPath));
return file.existsSync() ? file.path : null;
}
}
+12 -14
View File
@@ -1,6 +1,8 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import '../providers/server_provider.dart';
import '../providers/files_controller.dart';
import '../providers/session_controller.dart';
import 'native_channel.dart';
import 'share_intent_service.dart';
/// Hands a "Share to Noo" upload off to `ShareUploadService.kt`'s Android
@@ -18,25 +20,21 @@ class UploadService {
/// service owns the rest and reports progress/completion/cancellation
/// through its own notification, not back to the app.
static Future<void> startUpload(
ServerProvider provider,
List<SharedFileRef> files,
SessionController session,
FilesController files,
List<SharedFileRef> sharedFiles,
) async {
final service = provider.service;
final authHeader = service?.authHeaders['Authorization'];
if (service == null || authHeader == null) {
throw Exception('Not logged in.');
}
final args = baseChannelArgs(session);
final filesJson = jsonEncode(
files.map((f) => {'uri': f.uri, 'name': f.name, 'size': f.size}).toList(),
sharedFiles
.map((f) => {'uri': f.uri, 'name': f.name, 'size': f.size})
.toList(),
);
await _channel.invokeMethod('startUpload', {
...args,
'files': filesJson,
'serverUrl': provider.serverUrl,
'username': provider.username,
'authHeader': authHeader,
'remoteFolder': provider.currentFolderPath,
'remoteFolder': files.currentFolderPath,
});
}
}