Dedupe native transfer/notification code and split ServerProvider into focused controllers
Build APK / build (push) Successful in 5m20s
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:
@@ -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});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user