1056 lines
30 KiB
Dart
1056 lines
30 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
|
|
import 'package:aves/model/entry/entry.dart';
|
|
import 'package:aves/model/source/collection_source.dart';
|
|
import 'package:aves/model/source/events.dart';
|
|
|
|
import 'package:aves/remote/remote_settings.dart';
|
|
import 'package:aves/remote/remote_sync_bus.dart';
|
|
import 'package:aves/remote/remote_repository.dart';
|
|
import 'package:aves/services/common/services.dart';
|
|
import 'package:aves/remote/remote_client.dart';
|
|
import 'package:aves/remote/auth_client.dart';
|
|
import 'package:aves/remote/collection_source_remote_ext.dart';
|
|
import 'package:aves/remote/collection_source_remote_ws_ext.dart';
|
|
|
|
import 'remote_origin.dart';
|
|
import 'remote_http_api.dart';
|
|
import 'remote_sync_engine.dart';
|
|
import 'remote_state_store.dart';
|
|
import 'remote_ws_client.dart';
|
|
import 'remote_http.dart';
|
|
import 'remote_added_queue.dart';
|
|
|
|
|
|
class RemoteController {
|
|
RemoteController._();
|
|
static final RemoteController instance = RemoteController._();
|
|
|
|
static const _kBootstrapDone = 'remote_bootstrap_done';
|
|
|
|
bool _syncInFlight = false;
|
|
|
|
DateTime? _retryStartTime;
|
|
Timer? _retryTimer;
|
|
|
|
RemoteAuth? _auth;
|
|
RemoteHttpApi? _api;
|
|
RemoteSyncEngine? _engine;
|
|
RemoteWsClient? _ws;
|
|
RemoteStateStore? _stateStore;
|
|
RemoteAddedQueue? _addedQueue;
|
|
EventRingDeduper? _deduper;
|
|
|
|
Future<void>? _ensureStackFuture;
|
|
bool _wsConnectInFlight = false;
|
|
|
|
// coalescing progressive
|
|
Timer? _progressiveTimer;
|
|
bool _progressiveScheduled = false;
|
|
String? _pendingSinceIso;
|
|
bool _progressiveInFlight = false;
|
|
|
|
// ------------------------------------------------------------
|
|
// helper: forza refresh lens quando cambia remoteVisible
|
|
// ------------------------------------------------------------
|
|
void _notifyRemoteVisibilityChanged(CollectionSource source) {
|
|
try {
|
|
source.eventBus.fire(const FilterVisibilityChangedEvent());
|
|
} catch (_) {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
// refresh “forte” (la lens ascolta EntryRefreshedEvent e fa refresh())
|
|
void _forceCollectionRefresh(CollectionSource source) {
|
|
try {
|
|
source.eventBus.fire(const EntryRefreshedEvent(<AvesEntry>{}));
|
|
} catch (_) {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
|
|
Future<void> _waitSourceReady(
|
|
CollectionSource source, {
|
|
required String reason,
|
|
Duration timeout = const Duration(seconds: 45),
|
|
}) async {
|
|
if (source.isReady) {
|
|
debugPrint(
|
|
'[remote_controller] source already ready '
|
|
'reason=$reason '
|
|
'state=${source.state}',
|
|
);
|
|
return;
|
|
}
|
|
|
|
debugPrint(
|
|
'[remote_controller] waiting source ready '
|
|
'reason=$reason '
|
|
'state=${source.state}',
|
|
);
|
|
|
|
final completer = Completer<void>();
|
|
Timer? timer;
|
|
|
|
late VoidCallback listener;
|
|
|
|
listener = () {
|
|
if (source.isReady && !completer.isCompleted) {
|
|
completer.complete();
|
|
}
|
|
};
|
|
|
|
source.stateNotifier.addListener(listener);
|
|
|
|
timer = Timer(timeout, () {
|
|
if (!completer.isCompleted) {
|
|
debugPrint(
|
|
'[remote_controller] wait source ready timeout '
|
|
'reason=$reason '
|
|
'state=${source.state}',
|
|
);
|
|
completer.complete();
|
|
}
|
|
});
|
|
|
|
try {
|
|
listener();
|
|
await completer.future;
|
|
} finally {
|
|
timer.cancel();
|
|
source.stateNotifier.removeListener(listener);
|
|
}
|
|
|
|
debugPrint(
|
|
'[remote_controller] source ready wait done '
|
|
'reason=$reason '
|
|
'state=${source.state}',
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------
|
|
// bootstrap flag
|
|
// ------------------------------------------------------------
|
|
Future<bool> bootstrapDone() async {
|
|
const storage = FlutterSecureStorage();
|
|
return (await storage.read(key: _kBootstrapDone)) == '1';
|
|
}
|
|
|
|
Future<void> _setBootstrapDone() async {
|
|
const storage = FlutterSecureStorage();
|
|
await storage.write(key: _kBootstrapDone, value: '1');
|
|
}
|
|
|
|
// ------------------------------------------------------------
|
|
// init bus (usata da HomePage init)
|
|
// ------------------------------------------------------------
|
|
Future<void> initBusFromSettings() async {
|
|
final s = await RemoteSettings.load();
|
|
if (!s.enabled) {
|
|
RemoteSyncBus.instance.setDisabled();
|
|
return;
|
|
}
|
|
RemoteSyncBus.instance.stateNotifier.value = RemoteSyncState.syncing;
|
|
RemoteSyncBus.instance.progressNotifier.value = const RemoteSyncProgress(
|
|
done: 0,
|
|
total: 0,
|
|
showOverlay: false,
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------
|
|
// ws url
|
|
// ------------------------------------------------------------
|
|
String _deriveWsUrl(String baseUrl) {
|
|
final uri = Uri.parse(baseUrl);
|
|
final scheme = uri.scheme == 'http' ? 'ws' : 'wss';
|
|
final parts = uri.host.split('.');
|
|
if (parts.isEmpty) return '$scheme://${uri.host}';
|
|
parts[0] = '${parts[0]}-ws';
|
|
return '$scheme://${parts.join('.')}';
|
|
}
|
|
|
|
String _resolveWsUrl(RemoteSettings s) =>
|
|
s.wsUrl.trim().isNotEmpty ? s.wsUrl.trim() : _deriveWsUrl(s.baseUrl);
|
|
|
|
// ------------------------------------------------------------
|
|
// ensure stack single-flight
|
|
// ------------------------------------------------------------
|
|
Future<void> _ensureRealtimeStack(CollectionSource source) {
|
|
final existing = _ensureStackFuture;
|
|
if (existing != null) return existing;
|
|
|
|
final fut = _ensureRealtimeStackImpl(source);
|
|
_ensureStackFuture = fut;
|
|
return fut.whenComplete(() {
|
|
if (identical(_ensureStackFuture, fut)) _ensureStackFuture = null;
|
|
});
|
|
}
|
|
|
|
Future<void> _ensureRealtimeStackImpl(CollectionSource source) async {
|
|
final s = await RemoteSettings.load();
|
|
|
|
_stateStore ??= RemoteStateStore();
|
|
_deduper ??= EventRingDeduper(_stateStore!);
|
|
|
|
final wsUrl = _resolveWsUrl(s);
|
|
|
|
final rebuildAuth =
|
|
_auth == null ||
|
|
_auth!.email != s.email ||
|
|
_auth!.password != s.password ||
|
|
_auth!.base.toString() != (s.baseUrl.endsWith('/') ? s.baseUrl : '${s.baseUrl}/');
|
|
|
|
final rebuildWs = _ws == null || _ws!.wsUrl != wsUrl;
|
|
|
|
if (rebuildAuth || rebuildWs) {
|
|
_auth = RemoteAuth(baseUrl: s.baseUrl, email: s.email, password: s.password);
|
|
|
|
// allinea RemoteHttp (thumbs) allo stesso auth/token
|
|
RemoteHttp.attach(baseUrl: s.baseUrl, auth: _auth!);
|
|
await RemoteHttp.warmUp();
|
|
|
|
_api = RemoteHttpApi(baseUrl: s.baseUrl, auth: _auth!);
|
|
|
|
_engine = RemoteSyncEngine(
|
|
api: _api!,
|
|
repo: RemoteRepository(localMediaDb.rawDb),
|
|
source: source,
|
|
state: _stateStore!,
|
|
);
|
|
|
|
// chiudi eventuale ws precedente
|
|
_ws?.close(); // ignore: unawaited_futures
|
|
|
|
// crea la queue per batch di "added" con deduper e callback UI debounced
|
|
final addedQueue = RemoteAddedQueue(
|
|
api: _api!,
|
|
engine: _engine!,
|
|
deduper: _deduper!,
|
|
onUiRefresh: () {
|
|
unawaited(() async {
|
|
await source.appendRemoteEntriesFromDb();
|
|
_notifyRemoteVisibilityChanged(source);
|
|
}());
|
|
},
|
|
);
|
|
|
|
// dichiariamo la variabile prima per poterla referenziare nelle closure
|
|
late RemoteWsClient wsClient;
|
|
|
|
wsClient = RemoteWsClient(
|
|
wsUrl: wsUrl,
|
|
store: _stateStore!,
|
|
deduper: _deduper!,
|
|
|
|
// Chiamato solo dopo auth_ok, oppure dopo recovery_done.
|
|
// Solo qui i remoti possono diventare visibili.
|
|
onReady: () {
|
|
debugPrint('[remote_controller] WS ready -> set upToDate');
|
|
|
|
RemoteSyncBus.instance.stateNotifier.value = RemoteSyncState.upToDate;
|
|
RemoteSyncBus.instance.progressNotifier.value = null;
|
|
|
|
_notifyRemoteVisibilityChanged(source);
|
|
},
|
|
|
|
// Chiamato se il WS cade o non autentica.
|
|
// In questo caso i remoti devono restare nascosti.
|
|
onDisconnected: () {
|
|
debugPrint('[remote_controller] WS disconnected -> serverDown / hide remotes');
|
|
|
|
final current = RemoteSyncBus.instance.stateNotifier.value;
|
|
|
|
if (current != RemoteSyncState.disabled) {
|
|
RemoteSyncBus.instance.stateNotifier.value = RemoteSyncState.serverDown;
|
|
RemoteSyncBus.instance.progressNotifier.value = null;
|
|
|
|
source.invalidateEntries();
|
|
_notifyRemoteVisibilityChanged(source);
|
|
}
|
|
},
|
|
|
|
// Quando il server richiede recovery, esegui progressive sync
|
|
// e poi invia recovery_done. RemoteWsClient chiamerà onReady()
|
|
// dopo notifyRecoveryCompleted().
|
|
onNeedRecoverySync: () async {
|
|
try {
|
|
await _runProgressiveSync(source);
|
|
} finally {
|
|
wsClient.notifyRecoveryCompleted();
|
|
}
|
|
},
|
|
|
|
// Delega gli altri eventi al controller.
|
|
onEvent: (msg) => _handleWsEvent(source, msg),
|
|
);
|
|
|
|
// registra la queue nel client WS
|
|
wsClient.setAddedQueue(addedQueue);
|
|
|
|
// salva la queue e il client nello stato del controller
|
|
_addedQueue = addedQueue;
|
|
_ws = wsClient;
|
|
source.remoteWsClient = wsClient;
|
|
|
|
// opzionale: inizializza la queue se ha init/async setup
|
|
try {
|
|
await _addedQueue?.init();
|
|
} catch (_) {
|
|
// ignore init errors, queue funzionerà comunque
|
|
}
|
|
} else {
|
|
await _deduper!.init();
|
|
}
|
|
|
|
|
|
}
|
|
|
|
// ------------------------------------------------------------
|
|
// progressive coalescing
|
|
// ------------------------------------------------------------
|
|
void _requestProgressiveSync({
|
|
required CollectionSource source,
|
|
String? sinceIso,
|
|
Duration delay = const Duration(milliseconds: 600),
|
|
}) {
|
|
if (sinceIso != null && sinceIso.trim().isNotEmpty) {
|
|
_pendingSinceIso = _minIso(_pendingSinceIso, sinceIso.trim());
|
|
}
|
|
|
|
if (_progressiveScheduled) return;
|
|
_progressiveScheduled = true;
|
|
|
|
_progressiveTimer?.cancel();
|
|
_progressiveTimer = Timer(delay, () async {
|
|
_progressiveScheduled = false;
|
|
final since = _pendingSinceIso;
|
|
_pendingSinceIso = null;
|
|
|
|
try {
|
|
await _runProgressiveSync(source, since: since);
|
|
} catch (_) {}
|
|
});
|
|
}
|
|
|
|
String? _minIso(String? a, String b) {
|
|
if (a == null || a.isEmpty) return b;
|
|
final da = DateTime.tryParse(a);
|
|
final db = DateTime.tryParse(b);
|
|
if (da == null) return b;
|
|
if (db == null) return a;
|
|
return db.isBefore(da) ? b : a;
|
|
}
|
|
|
|
// ------------------------------------------------------------
|
|
// PATCH 1: progressive NON deve far tornare arancione se eri già verde
|
|
// ------------------------------------------------------------
|
|
Future<void> _runProgressiveSync(
|
|
CollectionSource source, {
|
|
String? since,
|
|
}) async {
|
|
if (_progressiveInFlight) {
|
|
debugPrint(
|
|
'[remote] progressive sync skipped: already in flight '
|
|
'since=$since',
|
|
);
|
|
return;
|
|
}
|
|
|
|
_progressiveInFlight = true;
|
|
|
|
try {
|
|
await _ensureRealtimeStack(source);
|
|
|
|
final engine = _engine;
|
|
if (engine == null) return;
|
|
|
|
final prev = RemoteSyncBus.instance.stateNotifier.value;
|
|
|
|
// Se eri già verde, non far lampeggiare l'icona in arancione.
|
|
final keepGreen = prev == RemoteSyncState.upToDate;
|
|
|
|
int? opId;
|
|
|
|
if (!keepGreen) {
|
|
opId = RemoteSyncBus.instance.start(
|
|
total: 0,
|
|
showOverlay: false,
|
|
);
|
|
|
|
_notifyRemoteVisibilityChanged(source);
|
|
}
|
|
|
|
try {
|
|
if (since != null && since.trim().isNotEmpty) {
|
|
await engine.progressiveSyncFrom(since.trim());
|
|
} else {
|
|
await engine.progressiveSync();
|
|
}
|
|
|
|
if (opId != null) {
|
|
RemoteSyncBus.instance.finishUpToDate(opId: opId);
|
|
} else {
|
|
// Resta verde.
|
|
RemoteSyncBus.instance.stateNotifier.value = RemoteSyncState.upToDate;
|
|
RemoteSyncBus.instance.progressNotifier.value = null;
|
|
}
|
|
|
|
// Importante:
|
|
// NON chiamare computeFolderMap qui.
|
|
// Il sync engine / appendRemoteEntriesFromDb aggiorna già DB/source/folderMap
|
|
// quando serve. Qui basta notificare la UI.
|
|
_notifyRemoteVisibilityChanged(source);
|
|
} catch (e, st) {
|
|
debugPrint('[remote] progressive sync failed: $e\n$st');
|
|
|
|
if (opId != null) {
|
|
RemoteSyncBus.instance.failServerDown(opId: opId);
|
|
} else {
|
|
RemoteSyncBus.instance.stateNotifier.value = RemoteSyncState.serverDown;
|
|
RemoteSyncBus.instance.progressNotifier.value = null;
|
|
}
|
|
|
|
_notifyRemoteVisibilityChanged(source);
|
|
|
|
rethrow;
|
|
}
|
|
} finally {
|
|
_progressiveInFlight = false;
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------
|
|
// ws start/stop
|
|
// ------------------------------------------------------------
|
|
Future<void> _startWsAlreadyEnsured() async {
|
|
if (_wsConnectInFlight) return;
|
|
_wsConnectInFlight = true;
|
|
|
|
try {
|
|
final s = await RemoteSettings.load();
|
|
if (!s.enabled) return;
|
|
if (_ws == null || _auth == null) return;
|
|
if (_ws!.isConnected) return;
|
|
|
|
final token = _auth!.token ?? await _auth!.login();
|
|
await _ws!.connect(token: token);
|
|
} finally {
|
|
_wsConnectInFlight = false;
|
|
}
|
|
}
|
|
|
|
Future<void> _stopWs(CollectionSource source) async {
|
|
_progressiveTimer?.cancel();
|
|
_progressiveTimer = null;
|
|
_progressiveScheduled = false;
|
|
_pendingSinceIso = null;
|
|
|
|
try {
|
|
await _ws?.close();
|
|
} catch (_) {}
|
|
|
|
try {
|
|
await _addedQueue?.dispose();
|
|
} catch (_) {}
|
|
|
|
_addedQueue = null;
|
|
|
|
_ws = null;
|
|
_engine = null;
|
|
_api = null;
|
|
_auth = null;
|
|
_ensureStackFuture = null;
|
|
_wsConnectInFlight = false;
|
|
|
|
_notifyRemoteVisibilityChanged(source);
|
|
}
|
|
|
|
// ------------------------------------------------------------
|
|
// ws events
|
|
// ------------------------------------------------------------
|
|
|
|
Future<bool> _handleWsEvent(CollectionSource source, Map<String, dynamic> msg) async {
|
|
final type = msg['type']?.toString();
|
|
if (type == null) return true;
|
|
|
|
// BULK add_dir / del_dir: se il server fornisce "since", esegui subito la sync mirata
|
|
if ((type == 'add_dir' || type == 'del_dir') && msg['mode'] == 'bulk') {
|
|
final since = msg['since']?.toString();
|
|
if (since != null && since.isNotEmpty) {
|
|
try {
|
|
await _runProgressiveSync(source, since: since);
|
|
return true;
|
|
} catch (_) {
|
|
// se fallisce, segnala al caller che non è stato applicato
|
|
return false;
|
|
}
|
|
}
|
|
// se non c'è since, accetta l'evento e aspetta eventuale add_dir_done
|
|
return true;
|
|
}
|
|
|
|
// add_dir_done / del_dir_done: server conferma fine bulk, esegui la sync mirata se presente
|
|
if ((type == 'add_dir_done' || type == 'del_dir_done') && msg['mode'] == 'bulk') {
|
|
final since = msg['since']?.toString();
|
|
try {
|
|
await _runProgressiveSync(source, since: since);
|
|
return true;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// added / del / removed / updated
|
|
// - per added: la RemoteAddedQueue è già registrata nel WS client e gestisce batching
|
|
// - per del/removed/updated: chiediamo una progressive coalesced (puoi ottimizzare per id)
|
|
if (type == 'added' || type == 'del' || type == 'removed' || type == 'updated') {
|
|
_requestProgressiveSync(source: source);
|
|
return true;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
|
|
// ------------------------------------------------------------
|
|
// lifecycle
|
|
// ------------------------------------------------------------
|
|
Future<void> onAppStart({
|
|
required CollectionSource source,
|
|
bool resumeBootstrapIfEnabled = true,
|
|
}) async {
|
|
final s = await RemoteSettings.load();
|
|
debugPrint('[remote-debug] onAppStart start settings.enabled=${s.enabled} bus=${RemoteSyncBus.instance.stateNotifier.value}');
|
|
|
|
if (!s.enabled) {
|
|
debugPrint('[remote-debug] remote disabled in settings -> disabling bus and hiding remotes');
|
|
|
|
RemoteSyncBus.instance.setDisabled();
|
|
|
|
debugPrint(
|
|
'[remote-debug] remote disabled: keeping remote entries in memory, hiding by remoteVisible',
|
|
);
|
|
|
|
// Non rimuoviamo remoti dalla RAM.
|
|
// Non facciamo computeFolderMap qui: addEntries/init lo fa già.
|
|
source.invalidateEntries();
|
|
|
|
_notifyRemoteVisibilityChanged(source);
|
|
|
|
await _stopWs(source);
|
|
|
|
debugPrint(
|
|
'[remote-debug] onAppStart end (remote disabled) '
|
|
'bus=${RemoteSyncBus.instance.stateNotifier.value}',
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
await _waitSourceReady(
|
|
source,
|
|
reason: 'onAppStart-remote-enabled',
|
|
);
|
|
|
|
/*
|
|
final done = await bootstrapDone();
|
|
debugPrint('[remote-debug] bootstrapDone=$done');
|
|
*/
|
|
|
|
var done = await bootstrapDone();
|
|
|
|
final cachedRemotes = await localMediaDb.loadEntries(
|
|
origin: RemoteOrigin.value,
|
|
);
|
|
|
|
final hasCachedRemotes = cachedRemotes.any((entry) => !entry.trashed);
|
|
|
|
debugPrint(
|
|
'[remote-debug] bootstrapDone=$done '
|
|
'cachedRemotes=${cachedRemotes.length} '
|
|
'hasCachedRemotes=$hasCachedRemotes',
|
|
);
|
|
|
|
// Se il flag bootstrap non esiste ma abbiamo già remoti nel DB,
|
|
// consideriamo il bootstrap già fatto.
|
|
// Questo evita fullSync/bootstrap inutili quando riattivi remote.
|
|
if (!done && hasCachedRemotes) {
|
|
debugPrint(
|
|
'[remote-debug] bootstrap flag missing but remote cache exists '
|
|
'-> mark bootstrap done and skip full bootstrap',
|
|
);
|
|
|
|
await _setBootstrapDone();
|
|
done = true;
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (!done) {
|
|
debugPrint(
|
|
'[remote_controller] first bootstrap start '
|
|
'bus=${RemoteSyncBus.instance.stateNotifier.value}',
|
|
);
|
|
|
|
RemoteSyncBus.instance.stateNotifier.value = RemoteSyncState.syncing;
|
|
RemoteSyncBus.instance.progressNotifier.value = const RemoteSyncProgress(
|
|
done: 0,
|
|
total: 0,
|
|
showOverlay: false,
|
|
);
|
|
|
|
_notifyRemoteVisibilityChanged(source);
|
|
|
|
debugPrint(
|
|
'[remote_controller] first bootstrap set syncing '
|
|
'bus=${RemoteSyncBus.instance.stateNotifier.value}',
|
|
);
|
|
|
|
try {
|
|
debugPrint('[remote_controller] first bootstrap -> ensure stack');
|
|
await _ensureRealtimeStack(source);
|
|
|
|
debugPrint('[remote_controller] first bootstrap -> fullSyncImpl');
|
|
await _engine!.fullSyncImpl();
|
|
|
|
try {
|
|
await _setBootstrapDone();
|
|
debugPrint('[remote_controller] bootstrap complete: flag set');
|
|
} catch (e, st) {
|
|
debugPrint('[remote_controller] _setBootstrapDone failed: $e\n$st');
|
|
}
|
|
|
|
// Dopo il primo bootstrap, carica subito i remoti dal DB nella source.
|
|
// debugPrint('[remote_controller] first bootstrap -> append remotes from DB');
|
|
// await source.appendRemoteEntriesFromDb();
|
|
|
|
|
|
|
|
// Aggiorna visibilità/filter senza forzare refresh pesante thumbnails.
|
|
_notifyRemoteVisibilityChanged(source);
|
|
|
|
// Avvia realtime WS.
|
|
// NON impostare upToDate qui.
|
|
// I remoti diventano visibili solo dopo auth_ok del WS,
|
|
// tramite RemoteWsClient.onReady().
|
|
debugPrint('[remote_controller] first bootstrap -> start WS');
|
|
|
|
try {
|
|
await _startWsAlreadyEnsured();
|
|
|
|
debugPrint(
|
|
'[remote_controller] first bootstrap WS start requested; '
|
|
'waiting auth_ok before upToDate',
|
|
);
|
|
} catch (e, st) {
|
|
debugPrint('[remote_controller] first bootstrap WS start failed: $e\n$st');
|
|
|
|
RemoteSyncBus.instance.stateNotifier.value = RemoteSyncState.serverDown;
|
|
RemoteSyncBus.instance.progressNotifier.value = null;
|
|
|
|
source.invalidateEntries();
|
|
_notifyRemoteVisibilityChanged(source);
|
|
|
|
rethrow;
|
|
}
|
|
|
|
debugPrint(
|
|
'[remote_controller] first bootstrap end, waiting WS auth_ok '
|
|
'bus=${RemoteSyncBus.instance.stateNotifier.value}',
|
|
);
|
|
// NON partire subito con progressive sync dopo full bootstrap.
|
|
// Il bootstrap ha appena allineato tutto.
|
|
// La progressive partirà da WS/eventi o resume.
|
|
|
|
|
|
|
|
} catch (e, st) {
|
|
debugPrint('[remote_controller] first bootstrap failed: $e\n$st');
|
|
|
|
RemoteSyncBus.instance.stateNotifier.value = RemoteSyncState.serverDown;
|
|
RemoteSyncBus.instance.progressNotifier.value = null;
|
|
|
|
_notifyRemoteVisibilityChanged(source);
|
|
_forceCollectionRefresh(source);
|
|
|
|
rethrow;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// prima di append: syncing + refresh
|
|
RemoteSyncBus.instance.stateNotifier.value = RemoteSyncState.syncing;
|
|
RemoteSyncBus.instance.progressNotifier.value =
|
|
const RemoteSyncProgress(done: 0, total: 0, showOverlay: false);
|
|
_notifyRemoteVisibilityChanged(source);
|
|
debugPrint('[remote-debug] set state=syncing before append');
|
|
|
|
// append remoti dal DB (batching nella collection)
|
|
debugPrint('[remote-debug] calling appendRemoteEntriesFromDb()');
|
|
await source.appendRemoteEntriesFromDb();
|
|
debugPrint('[remote-debug] after append state=${RemoteSyncBus.instance.stateNotifier.value} progress=${RemoteSyncBus.instance.progressNotifier.value}');
|
|
|
|
// forza refresh Home (cache/lens)
|
|
_notifyRemoteVisibilityChanged(source);
|
|
debugPrint('[remote-debug] notified visibility after append');
|
|
|
|
// Avvia lo stack realtime.
|
|
// Dopo appendRemoteEntriesFromDb(), i remoti sono in RAM/DB,
|
|
// ma restano nascosti finché il WS non conferma auth_ok.
|
|
debugPrint('[remote-debug] ensuring realtime stack');
|
|
await _ensureRealtimeStack(source);
|
|
|
|
debugPrint('[remote-debug] ensureRealtimeStack done, starting WS');
|
|
|
|
try {
|
|
await _startWsAlreadyEnsured();
|
|
|
|
debugPrint(
|
|
'[remote-debug] WS start requested '
|
|
'ws client present=${_ws != null} '
|
|
'wsConnected=${_ws?.isConnected}',
|
|
);
|
|
|
|
// IMPORTANTE:
|
|
// NON impostare upToDate qui.
|
|
// _startWsAlreadyEnsured() avvia la connessione e manda auth,
|
|
// ma auth_ok arriverà asincrono dallo stream WS.
|
|
//
|
|
// RemoteSyncState.upToDate verrà impostato solo da:
|
|
// RemoteWsClient.onReady()
|
|
debugPrint('[remote-debug] waiting WS auth_ok before showing remotes');
|
|
} catch (e, st) {
|
|
debugPrint('[remote-debug] WS start failed -> serverDown: $e\n$st');
|
|
|
|
RemoteSyncBus.instance.stateNotifier.value = RemoteSyncState.serverDown;
|
|
RemoteSyncBus.instance.progressNotifier.value = null;
|
|
|
|
source.invalidateEntries();
|
|
_notifyRemoteVisibilityChanged(source);
|
|
|
|
return;
|
|
}
|
|
|
|
debugPrint('[remote-debug] exiting onAppStart without immediate progressive sync');
|
|
|
|
}
|
|
|
|
|
|
Future<void> toggleRemote({required CollectionSource source}) async {
|
|
final s = await RemoteSettings.load();
|
|
|
|
debugPrint(
|
|
'[remote_controller] toggleRemote start '
|
|
'enabledBefore=${s.enabled} '
|
|
'busBefore=${RemoteSyncBus.instance.stateNotifier.value}',
|
|
);
|
|
|
|
|
|
final upd = RemoteSettings(
|
|
enabled: !s.enabled,
|
|
baseUrl: s.baseUrl,
|
|
indexPath: s.indexPath,
|
|
email: s.email,
|
|
password: s.password,
|
|
wsUrl: s.wsUrl,
|
|
);
|
|
await upd.save();
|
|
|
|
debugPrint(
|
|
'[remote_controller] toggleRemote saved '
|
|
'enabledAfter=${upd.enabled} '
|
|
'bus=${RemoteSyncBus.instance.stateNotifier.value}',
|
|
);
|
|
|
|
if (!upd.enabled) {
|
|
debugPrint(
|
|
'[remote_controller] disabling remote '
|
|
'busBeforeDisable=${RemoteSyncBus.instance.stateNotifier.value}',
|
|
);
|
|
|
|
RemoteSyncBus.instance.setDisabled();
|
|
|
|
debugPrint(
|
|
'[remote_controller] disabled remote '
|
|
'busAfterDisable=${RemoteSyncBus.instance.stateNotifier.value}',
|
|
);
|
|
|
|
debugPrint(
|
|
'[remote_controller] disabling remote: keeping remote entries in memory, hiding by remoteVisible',
|
|
);
|
|
|
|
source.invalidateEntries();
|
|
//await source.computeFolderMap(reason: 'remote-disabled-toggle');
|
|
//await source.updateDirectories();
|
|
|
|
_notifyRemoteVisibilityChanged(source);
|
|
|
|
await _stopWs(source);
|
|
return;
|
|
}
|
|
|
|
// ------------------------------------------------------------
|
|
// PATCH 2: se è la prima volta, mostra overlay (barra progresso) al bootstrap
|
|
// ------------------------------------------------------------
|
|
final first = !(await bootstrapDone());
|
|
|
|
|
|
debugPrint(
|
|
'[remote_controller] toggleRemote enabling '
|
|
'first=$first '
|
|
'busBeforeOnAppStart=${RemoteSyncBus.instance.stateNotifier.value}',
|
|
);
|
|
|
|
await onAppStart(source: source, resumeBootstrapIfEnabled: first);
|
|
|
|
|
|
debugPrint(
|
|
'[remote_controller] toggleRemote after onAppStart '
|
|
'busAfterOnAppStart=${RemoteSyncBus.instance.stateNotifier.value}',
|
|
);
|
|
|
|
|
|
}
|
|
|
|
// ------------------------------------------------------------
|
|
// full sync legacy (progress + overlay)
|
|
// ------------------------------------------------------------
|
|
Future<void> fullSync({
|
|
required CollectionSource source,
|
|
required bool showOverlay,
|
|
bool markBootstrapDoneOnSuccess = false,
|
|
}) async {
|
|
if (_syncInFlight) return;
|
|
_syncInFlight = true;
|
|
|
|
final s = await RemoteSettings.load();
|
|
if (!s.enabled) {
|
|
RemoteSyncBus.instance.setDisabled();
|
|
_notifyRemoteVisibilityChanged(source);
|
|
_forceCollectionRefresh(source);
|
|
_syncInFlight = false;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (s.baseUrl.trim().isEmpty) {
|
|
RemoteSyncBus.instance.stateNotifier.value = RemoteSyncState.serverDown;
|
|
RemoteSyncBus.instance.progressNotifier.value = null;
|
|
_notifyRemoteVisibilityChanged(source);
|
|
_forceCollectionRefresh(source);
|
|
_syncInFlight = false;
|
|
return;
|
|
}
|
|
|
|
RemoteAuth? auth;
|
|
if (s.email.isNotEmpty && s.password.isNotEmpty) {
|
|
auth = RemoteAuth(baseUrl: s.baseUrl, email: s.email, password: s.password);
|
|
}
|
|
|
|
final client = RemoteJsonClient(s.baseUrl, s.indexPath, auth: auth);
|
|
await client.ping().timeout(const Duration(seconds: 3));
|
|
|
|
final items = await client.fetchAll().timeout(const Duration(seconds: 30));
|
|
final total = items.length;
|
|
|
|
final opId = RemoteSyncBus.instance.start(total: total, showOverlay: showOverlay);
|
|
_notifyRemoteVisibilityChanged(source);
|
|
|
|
final repo = RemoteRepository(localMediaDb.rawDb);
|
|
|
|
// Decide se è bootstrap (solo in quel caso deleteAllRemotes)
|
|
final isBootstrap = !(await bootstrapDone());
|
|
final serverIds = items.map((e) => e.id).where((v) => v.isNotEmpty).toSet();
|
|
|
|
if (isBootstrap) {
|
|
debugPrint('[remote-debug] fullSync bootstrap: deleteAllRemotes + upsert chunked');
|
|
await repo.deleteAllRemotes();
|
|
// upsert in chunk più piccoli per non bloccare il main thread
|
|
const chunk = 40;
|
|
int done = 0;
|
|
for (var i = 0; i < total; i += chunk) {
|
|
final end = (i + chunk < total) ? i + chunk : total;
|
|
await repo.upsertAll(items.sublist(i, end), chunkSize: chunk);
|
|
done = end;
|
|
RemoteSyncBus.instance.update(opId: opId, done: done, total: total);
|
|
}
|
|
try {
|
|
await _setBootstrapDone();
|
|
debugPrint('[remote-debug] bootstrap flag set after fullSync');
|
|
} catch (e, st) {
|
|
debugPrint('[remote-debug] _setBootstrapDone failed: $e\n$st');
|
|
}
|
|
} else {
|
|
debugPrint('[remote-debug] fullSync incremental: upsert chunked + pruneMissingRemotes');
|
|
const chunk = 40;
|
|
int done = 0;
|
|
for (var i = 0; i < total; i += chunk) {
|
|
final end = (i + chunk < total) ? i + chunk : total;
|
|
await repo.upsertAll(items.sublist(i, end), chunkSize: chunk);
|
|
done = end;
|
|
RemoteSyncBus.instance.update(opId: opId, done: done, total: total);
|
|
}
|
|
await repo.pruneMissingRemotes(serverIds);
|
|
}
|
|
|
|
// append remoti dal DB (collection batching)
|
|
await source.appendRemoteEntriesFromDb();
|
|
|
|
_notifyRemoteVisibilityChanged(source);
|
|
|
|
// Non impostare upToDate qui.
|
|
// Dopo il fullSync i dati sono nel DB/source, ma i remoti devono diventare
|
|
// visibili solo dopo auth_ok del WebSocket, tramite RemoteWsClient.onReady().
|
|
RemoteSyncBus.instance.progressNotifier.value = null;
|
|
_notifyRemoteVisibilityChanged(source);
|
|
|
|
// Assicurati che lo stack realtime sia avviato e poi avvia WS.
|
|
// onReady() imposterà upToDate dopo auth_ok.
|
|
await _ensureRealtimeStack(source);
|
|
|
|
try {
|
|
await _startWsAlreadyEnsured();
|
|
|
|
debugPrint(
|
|
'[remote] fullSync completed, WS start requested; '
|
|
'waiting auth_ok before showing remotes',
|
|
);
|
|
} catch (e, st) {
|
|
debugPrint('[remote] fullSync WS start failed: $e\n$st');
|
|
|
|
RemoteSyncBus.instance.stateNotifier.value = RemoteSyncState.serverDown;
|
|
RemoteSyncBus.instance.progressNotifier.value = null;
|
|
|
|
source.invalidateEntries();
|
|
_notifyRemoteVisibilityChanged(source);
|
|
}
|
|
|
|
} catch (e, st) {
|
|
debugPrint('[remote] fullSync error: $e\n$st');
|
|
RemoteSyncBus.instance.stateNotifier.value = RemoteSyncState.serverDown;
|
|
RemoteSyncBus.instance.progressNotifier.value = null;
|
|
_notifyRemoteVisibilityChanged(source);
|
|
_forceCollectionRefresh(source);
|
|
|
|
_retryStartTime ??= DateTime.now();
|
|
_scheduleRetry(source);
|
|
} finally {
|
|
_syncInFlight = false;
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------
|
|
// retry
|
|
// ------------------------------------------------------------
|
|
void _scheduleRetry(CollectionSource source) {
|
|
_retryTimer?.cancel();
|
|
|
|
_retryTimer = Timer(const Duration(seconds: 30), () async {
|
|
final s = await RemoteSettings.load();
|
|
if (!s.enabled) return;
|
|
|
|
if (_retryStartTime != null) {
|
|
final elapsed = DateTime.now().difference(_retryStartTime!);
|
|
if (elapsed > const Duration(minutes: 5)) {
|
|
debugPrint('[remote] retry timeout -> disattivo remote');
|
|
|
|
debugPrint('[remote] retry timeout: keeping remote entries in memory, hiding by remoteVisible');
|
|
|
|
source.invalidateEntries();
|
|
// await source.computeFolderMap(reason: 'remote-disabled-retry-timeout');
|
|
// await source.updateDirectories();
|
|
_notifyRemoteVisibilityChanged(source);
|
|
|
|
final upd = RemoteSettings(
|
|
enabled: false,
|
|
baseUrl: s.baseUrl,
|
|
indexPath: s.indexPath,
|
|
email: s.email,
|
|
password: s.password,
|
|
wsUrl: s.wsUrl,
|
|
);
|
|
await upd.save();
|
|
|
|
RemoteSyncBus.instance.setDisabled();
|
|
_notifyRemoteVisibilityChanged(source);
|
|
//_forceCollectionRefresh(source);
|
|
|
|
await _stopWs(source);
|
|
return;
|
|
}
|
|
}
|
|
|
|
debugPrint('[remote] retry ping…');
|
|
|
|
final auth = (s.email.isNotEmpty && s.password.isNotEmpty)
|
|
? RemoteAuth(baseUrl: s.baseUrl, email: s.email, password: s.password)
|
|
: null;
|
|
|
|
final retryClient = RemoteJsonClient(s.baseUrl, s.indexPath, auth: auth);
|
|
|
|
try {
|
|
await retryClient.ping().timeout(const Duration(seconds: 3));
|
|
debugPrint('[remote] retry OK -> riprendo sync');
|
|
|
|
_retryTimer = null;
|
|
_retryStartTime = null;
|
|
|
|
unawaited(fullSync(source: source, showOverlay: false));
|
|
} catch (_) {
|
|
debugPrint('[remote] retry fallito');
|
|
_scheduleRetry(source);
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> onResume(CollectionSource source) async {
|
|
final s = await RemoteSettings.load();
|
|
if (!s.enabled) return;
|
|
|
|
await _ensureRealtimeStack(source);
|
|
|
|
try {
|
|
await _startWsAlreadyEnsured();
|
|
|
|
debugPrint(
|
|
'[remote] onResume WS start requested; '
|
|
'waiting auth_ok before showing remotes',
|
|
);
|
|
} catch (e, st) {
|
|
debugPrint('[remote] onResume WS start failed: $e\n$st');
|
|
|
|
RemoteSyncBus.instance.stateNotifier.value = RemoteSyncState.serverDown;
|
|
RemoteSyncBus.instance.progressNotifier.value = null;
|
|
|
|
source.invalidateEntries();
|
|
_notifyRemoteVisibilityChanged(source);
|
|
}
|
|
|
|
// Non schedulare progressive qui.
|
|
// Se serve recovery, sarà il WS/server a chiederla con need_recovery.
|
|
}
|
|
|
|
}
|