aves_mio0.31/lib/remote/remote_controller.dart
2026-07-18 13:39:22 +02:00

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.
}
}