mirror of
https://github.com/immich-app/immich.git
synced 2025-12-24 09:14:58 +03:00
feat: user sync stream (#16862)
* refactor: user entity * chore: rebase fixes * refactor: remove int user Id * refactor: migrate store userId from int to string * refactor: rename uid to id * feat: drift * pr feedback * refactor: move common overrides to mixin * refactor: remove int user Id * refactor: migrate store userId from int to string * refactor: rename uid to id * feat: user & partner sync stream * pr changes * refactor: sync service and add tests * chore: remove generated change * chore: move sync model * rebase: convert string ids to byte uuids * rebase * add processing logs * batch db calls * rewrite isolate manager * rewrite with worker_manager * misc fixes * add sync order test --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
@@ -5,5 +5,9 @@ const double downloadFailed = -2;
|
||||
// Number of log entries to retain on app start
|
||||
const int kLogTruncateLimit = 250;
|
||||
|
||||
// Sync
|
||||
const int kSyncEventBatchSize = 5000;
|
||||
|
||||
// Hash batch limits
|
||||
const int kBatchHashFileLimit = 128;
|
||||
const int kBatchHashSizeLimit = 1024 * 1024 * 1024; // 1GB
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import 'package:immich_mobile/domain/models/sync/sync_event.model.dart';
|
||||
import 'package:immich_mobile/domain/models/sync_event.model.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
abstract interface class ISyncApiRepository {
|
||||
Future<void> ack(String data);
|
||||
Future<void> ack(List<String> data);
|
||||
|
||||
Stream<List<SyncEvent>> watchUserSyncEvent();
|
||||
Stream<List<SyncEvent>> getSyncEvents(List<SyncRequestType> type);
|
||||
}
|
||||
|
||||
10
mobile/lib/domain/interfaces/sync_stream.interface.dart
Normal file
10
mobile/lib/domain/interfaces/sync_stream.interface.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import 'package:immich_mobile/domain/interfaces/db.interface.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
abstract interface class ISyncStreamRepository implements IDatabaseRepository {
|
||||
Future<bool> updateUsersV1(Iterable<SyncUserV1> data);
|
||||
Future<bool> deleteUsersV1(Iterable<SyncUserDeleteV1> data);
|
||||
|
||||
Future<bool> updatePartnerV1(Iterable<SyncPartnerV1> data);
|
||||
Future<bool> deletePartnerV1(Iterable<SyncPartnerDeleteV1> data);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
class SyncEvent {
|
||||
// dynamic
|
||||
final dynamic data;
|
||||
|
||||
final String ack;
|
||||
|
||||
SyncEvent({
|
||||
required this.data,
|
||||
required this.ack,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => 'SyncEvent(data: $data, ack: $ack)';
|
||||
}
|
||||
13
mobile/lib/domain/models/sync_event.model.dart
Normal file
13
mobile/lib/domain/models/sync_event.model.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class SyncEvent {
|
||||
final SyncEntityType type;
|
||||
// ignore: avoid-dynamic
|
||||
final dynamic data;
|
||||
final String ack;
|
||||
|
||||
const SyncEvent({required this.type, required this.data, required this.ack});
|
||||
|
||||
@override
|
||||
String toString() => 'SyncEvent(type: $type, data: $data, ack: $ack)';
|
||||
}
|
||||
@@ -1,49 +1,200 @@
|
||||
// ignore_for_file: avoid-passing-async-when-sync-expected
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/sync_api.interface.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/sync_stream.interface.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:worker_manager/worker_manager.dart';
|
||||
|
||||
const _kSyncTypeOrder = [
|
||||
SyncEntityType.userDeleteV1,
|
||||
SyncEntityType.userV1,
|
||||
SyncEntityType.partnerDeleteV1,
|
||||
SyncEntityType.partnerV1,
|
||||
SyncEntityType.assetDeleteV1,
|
||||
SyncEntityType.assetV1,
|
||||
SyncEntityType.assetExifV1,
|
||||
SyncEntityType.partnerAssetDeleteV1,
|
||||
SyncEntityType.partnerAssetV1,
|
||||
SyncEntityType.partnerAssetExifV1,
|
||||
];
|
||||
|
||||
class SyncStreamService {
|
||||
final Logger _logger = Logger('SyncStreamService');
|
||||
|
||||
final ISyncApiRepository _syncApiRepository;
|
||||
final ISyncStreamRepository _syncStreamRepository;
|
||||
final bool Function()? _cancelChecker;
|
||||
|
||||
SyncStreamService(this._syncApiRepository);
|
||||
SyncStreamService({
|
||||
required ISyncApiRepository syncApiRepository,
|
||||
required ISyncStreamRepository syncStreamRepository,
|
||||
bool Function()? cancelChecker,
|
||||
}) : _syncApiRepository = syncApiRepository,
|
||||
_syncStreamRepository = syncStreamRepository,
|
||||
_cancelChecker = cancelChecker;
|
||||
|
||||
StreamSubscription? _userSyncSubscription;
|
||||
Future<bool> _handleSyncData(
|
||||
SyncEntityType type,
|
||||
// ignore: avoid-dynamic
|
||||
Iterable<dynamic> data,
|
||||
) async {
|
||||
if (data.isEmpty) {
|
||||
_logger.warning("Received empty sync data for $type");
|
||||
return false;
|
||||
}
|
||||
|
||||
void syncUsers() {
|
||||
_userSyncSubscription =
|
||||
_syncApiRepository.watchUserSyncEvent().listen((events) async {
|
||||
for (final event in events) {
|
||||
if (event.data is SyncUserV1) {
|
||||
final data = event.data as SyncUserV1;
|
||||
debugPrint("User Update: $data");
|
||||
_logger.fine("Processing sync data for $type of length ${data.length}");
|
||||
|
||||
// final user = await _userRepository.get(data.id);
|
||||
|
||||
// if (user == null) {
|
||||
// continue;
|
||||
// }
|
||||
|
||||
// user.name = data.name;
|
||||
// user.email = data.email;
|
||||
// user.updatedAt = DateTime.now();
|
||||
|
||||
// await _userRepository.update(user);
|
||||
// await _syncApiRepository.ack(event.ack);
|
||||
}
|
||||
|
||||
if (event.data is SyncUserDeleteV1) {
|
||||
final data = event.data as SyncUserDeleteV1;
|
||||
|
||||
debugPrint("User delete: $data");
|
||||
// await _syncApiRepository.ack(event.ack);
|
||||
}
|
||||
try {
|
||||
if (type == SyncEntityType.partnerV1) {
|
||||
return await _syncStreamRepository.updatePartnerV1(data.cast());
|
||||
}
|
||||
|
||||
if (type == SyncEntityType.partnerDeleteV1) {
|
||||
return await _syncStreamRepository.deletePartnerV1(data.cast());
|
||||
}
|
||||
|
||||
if (type == SyncEntityType.userV1) {
|
||||
return await _syncStreamRepository.updateUsersV1(data.cast());
|
||||
}
|
||||
|
||||
if (type == SyncEntityType.userDeleteV1) {
|
||||
return await _syncStreamRepository.deleteUsersV1(data.cast());
|
||||
}
|
||||
} catch (error, stack) {
|
||||
_logger.severe("Error processing sync data for $type", error, stack);
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.warning("Unknown sync data type: $type");
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _syncEvent(List<SyncRequestType> types) {
|
||||
_logger.info("Syncing Events: $types");
|
||||
final streamCompleter = Completer();
|
||||
bool shouldComplete = false;
|
||||
// the onDone callback might fire before the events are processed
|
||||
// the following flag ensures that the onDone callback is not called
|
||||
// before the events are processed and also that events are processed sequentially
|
||||
Completer? mutex;
|
||||
StreamSubscription? subscription;
|
||||
try {
|
||||
subscription = _syncApiRepository.getSyncEvents(types).listen(
|
||||
(events) async {
|
||||
if (events.isEmpty) {
|
||||
_logger.warning("Received empty sync events");
|
||||
return;
|
||||
}
|
||||
|
||||
// If previous events are still being processed, wait for them to finish
|
||||
if (mutex != null) {
|
||||
await mutex!.future;
|
||||
}
|
||||
|
||||
if (_cancelChecker?.call() ?? false) {
|
||||
_logger.info("Sync cancelled, stopping stream");
|
||||
subscription?.cancel();
|
||||
if (!streamCompleter.isCompleted) {
|
||||
streamCompleter.completeError(
|
||||
CanceledError(),
|
||||
StackTrace.current,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Take control of the mutex and process the events
|
||||
mutex = Completer();
|
||||
|
||||
try {
|
||||
final eventsMap = events.groupListsBy((event) => event.type);
|
||||
final Map<SyncEntityType, String> acks = {};
|
||||
|
||||
for (final type in _kSyncTypeOrder) {
|
||||
final data = eventsMap[type];
|
||||
if (data == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_cancelChecker?.call() ?? false) {
|
||||
_logger.info("Sync cancelled, stopping stream");
|
||||
mutex?.complete();
|
||||
mutex = null;
|
||||
if (!streamCompleter.isCompleted) {
|
||||
streamCompleter.completeError(
|
||||
CanceledError(),
|
||||
StackTrace.current,
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.isEmpty) {
|
||||
_logger.warning("Received empty sync events for $type");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (await _handleSyncData(type, data.map((e) => e.data))) {
|
||||
// ignore: avoid-unsafe-collection-methods
|
||||
acks[type] = data.last.ack;
|
||||
} else {
|
||||
_logger.warning("Failed to handle sync events for $type");
|
||||
}
|
||||
}
|
||||
|
||||
if (acks.isNotEmpty) {
|
||||
await _syncApiRepository.ack(acks.values.toList());
|
||||
}
|
||||
_logger.info("$types events processed");
|
||||
} catch (error, stack) {
|
||||
_logger.warning("Error handling sync events", error, stack);
|
||||
} finally {
|
||||
mutex?.complete();
|
||||
mutex = null;
|
||||
}
|
||||
|
||||
if (shouldComplete) {
|
||||
_logger.info("Sync done, completing stream");
|
||||
if (!streamCompleter.isCompleted) streamCompleter.complete();
|
||||
}
|
||||
},
|
||||
onError: (error, stack) {
|
||||
_logger.warning("Error in sync stream for $types", error, stack);
|
||||
// Do not proceed if the stream errors
|
||||
if (!streamCompleter.isCompleted) {
|
||||
// ignore: avoid-missing-completer-stack-trace
|
||||
streamCompleter.completeError(error, stack);
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
_logger.info("$types stream done");
|
||||
if (mutex == null && !streamCompleter.isCompleted) {
|
||||
streamCompleter.complete();
|
||||
} else {
|
||||
// Marks the stream as done but does not complete the completer
|
||||
// until the events are processed
|
||||
shouldComplete = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (error, stack) {
|
||||
_logger.severe("Error starting sync stream", error, stack);
|
||||
if (!streamCompleter.isCompleted) {
|
||||
streamCompleter.completeError(error, stack);
|
||||
}
|
||||
}
|
||||
return streamCompleter.future.whenComplete(() {
|
||||
_logger.info("Sync stream completed");
|
||||
return subscription?.cancel();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await _userSyncSubscription?.cancel();
|
||||
}
|
||||
Future<void> syncUsers() =>
|
||||
_syncEvent([SyncRequestType.usersV1, SyncRequestType.partnersV1]);
|
||||
}
|
||||
|
||||
37
mobile/lib/domain/utils/background_sync.dart
Normal file
37
mobile/lib/domain/utils/background_sync.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
// ignore_for_file: avoid-passing-async-when-sync-expected
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:immich_mobile/providers/infrastructure/sync_stream.provider.dart';
|
||||
import 'package:immich_mobile/utils/isolate.dart';
|
||||
import 'package:worker_manager/worker_manager.dart';
|
||||
|
||||
class BackgroundSyncManager {
|
||||
Cancelable<void>? _userSyncTask;
|
||||
|
||||
BackgroundSyncManager();
|
||||
|
||||
Future<void> cancel() {
|
||||
final futures = <Future>[];
|
||||
if (_userSyncTask != null) {
|
||||
futures.add(_userSyncTask!.future);
|
||||
}
|
||||
_userSyncTask?.cancel();
|
||||
_userSyncTask = null;
|
||||
return Future.wait(futures);
|
||||
}
|
||||
|
||||
Future<void> syncUsers() {
|
||||
if (_userSyncTask != null) {
|
||||
return _userSyncTask!.future;
|
||||
}
|
||||
|
||||
_userSyncTask = runInIsolateGentle(
|
||||
computation: (ref) => ref.read(syncStreamServiceProvider).syncUsers(),
|
||||
);
|
||||
_userSyncTask!.whenComplete(() {
|
||||
_userSyncTask = null;
|
||||
});
|
||||
return _userSyncTask!.future;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:uuid/parsing.dart';
|
||||
|
||||
extension StringExtension on String {
|
||||
String capitalize() {
|
||||
return split(" ")
|
||||
@@ -29,3 +33,8 @@ extension DurationExtension on String {
|
||||
return int.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
extension UUIDExtension on String {
|
||||
Uint8List toUuidByte({bool shouldValidate = false}) =>
|
||||
UuidParsing.parseAsByteList(this, validate: shouldValidate);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,36 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/sync_api.interface.dart';
|
||||
import 'package:immich_mobile/domain/models/sync/sync_event.model.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/sync_api.interface.dart';
|
||||
import 'package:immich_mobile/domain/models/sync_event.model.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class SyncApiRepository implements ISyncApiRepository {
|
||||
final Logger _logger = Logger('SyncApiRepository');
|
||||
final ApiService _api;
|
||||
const SyncApiRepository(this._api);
|
||||
final int _batchSize;
|
||||
SyncApiRepository(this._api, {int batchSize = kSyncEventBatchSize})
|
||||
: _batchSize = batchSize;
|
||||
|
||||
@override
|
||||
Stream<List<SyncEvent>> watchUserSyncEvent() {
|
||||
return _getSyncStream(
|
||||
SyncStreamDto(types: [SyncRequestType.usersV1]),
|
||||
);
|
||||
Stream<List<SyncEvent>> getSyncEvents(List<SyncRequestType> type) {
|
||||
return _getSyncStream(SyncStreamDto(types: type));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> ack(String data) {
|
||||
return _api.syncApi.sendSyncAck(SyncAckSetDto(acks: [data]));
|
||||
Future<void> ack(List<String> data) {
|
||||
return _api.syncApi.sendSyncAck(SyncAckSetDto(acks: data));
|
||||
}
|
||||
|
||||
Stream<List<SyncEvent>> _getSyncStream(
|
||||
SyncStreamDto dto, {
|
||||
int batchSize = 5000,
|
||||
}) async* {
|
||||
Stream<List<SyncEvent>> _getSyncStream(SyncStreamDto dto) async* {
|
||||
final client = http.Client();
|
||||
final endpoint = "${_api.apiClient.basePath}/sync/stream";
|
||||
|
||||
final headers = <String, String>{
|
||||
final headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/jsonlines+json',
|
||||
};
|
||||
@@ -61,52 +60,54 @@ class SyncApiRepository implements ISyncApiRepository {
|
||||
|
||||
await for (final chunk in response.stream.transform(utf8.decoder)) {
|
||||
previousChunk += chunk;
|
||||
final parts = previousChunk.split('\n');
|
||||
final parts = previousChunk.toString().split('\n');
|
||||
previousChunk = parts.removeLast();
|
||||
lines.addAll(parts);
|
||||
|
||||
if (lines.length < batchSize) {
|
||||
if (lines.length < _batchSize) {
|
||||
continue;
|
||||
}
|
||||
|
||||
yield await compute(_parseSyncResponse, lines);
|
||||
yield _parseSyncResponse(lines);
|
||||
lines.clear();
|
||||
}
|
||||
} finally {
|
||||
if (lines.isNotEmpty) {
|
||||
yield await compute(_parseSyncResponse, lines);
|
||||
yield _parseSyncResponse(lines);
|
||||
}
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
List<SyncEvent> _parseSyncResponse(List<String> lines) {
|
||||
final List<SyncEvent> data = [];
|
||||
|
||||
for (final line in lines) {
|
||||
try {
|
||||
final jsonData = jsonDecode(line);
|
||||
final type = SyncEntityType.fromJson(jsonData['type'])!;
|
||||
final dataJson = jsonData['data'];
|
||||
final ack = jsonData['ack'];
|
||||
final converter = _kResponseMap[type];
|
||||
if (converter == null) {
|
||||
_logger.warning("[_parseSyncResponse] Unknown type $type");
|
||||
continue;
|
||||
}
|
||||
|
||||
data.add(SyncEvent(type: type, data: converter(dataJson), ack: ack));
|
||||
} catch (error, stack) {
|
||||
_logger.severe("[_parseSyncResponse] Error parsing json", error, stack);
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
// ignore: avoid-dynamic
|
||||
const _kResponseMap = <SyncEntityType, Function(dynamic)>{
|
||||
SyncEntityType.userV1: SyncUserV1.fromJson,
|
||||
SyncEntityType.userDeleteV1: SyncUserDeleteV1.fromJson,
|
||||
SyncEntityType.partnerV1: SyncPartnerV1.fromJson,
|
||||
SyncEntityType.partnerDeleteV1: SyncPartnerDeleteV1.fromJson,
|
||||
};
|
||||
|
||||
// Need to be outside of the class to be able to use compute
|
||||
List<SyncEvent> _parseSyncResponse(List<String> lines) {
|
||||
final List<SyncEvent> data = [];
|
||||
|
||||
for (var line in lines) {
|
||||
try {
|
||||
final jsonData = jsonDecode(line);
|
||||
final type = SyncEntityType.fromJson(jsonData['type'])!;
|
||||
final dataJson = jsonData['data'];
|
||||
final ack = jsonData['ack'];
|
||||
final converter = _kResponseMap[type];
|
||||
if (converter == null) {
|
||||
debugPrint("[_parseSyncReponse] Unknown type $type");
|
||||
continue;
|
||||
}
|
||||
|
||||
data.add(SyncEvent(data: converter(dataJson), ack: ack));
|
||||
} catch (error, stack) {
|
||||
debugPrint("[_parseSyncReponse] Error parsing json $error $stack");
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/sync_stream.interface.dart';
|
||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class DriftSyncStreamRepository extends DriftDatabaseRepository
|
||||
implements ISyncStreamRepository {
|
||||
final Logger _logger = Logger('DriftSyncStreamRepository');
|
||||
final Drift _db;
|
||||
|
||||
DriftSyncStreamRepository(super.db) : _db = db;
|
||||
|
||||
@override
|
||||
Future<bool> deleteUsersV1(Iterable<SyncUserDeleteV1> data) async {
|
||||
try {
|
||||
await _db.batch((batch) {
|
||||
for (final user in data) {
|
||||
batch.delete(
|
||||
_db.userEntity,
|
||||
UserEntityCompanion(id: Value(user.userId.toUuidByte())),
|
||||
);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
} catch (e, s) {
|
||||
_logger.severe('Error while processing SyncUserDeleteV1', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> updateUsersV1(Iterable<SyncUserV1> data) async {
|
||||
try {
|
||||
await _db.batch((batch) {
|
||||
for (final user in data) {
|
||||
final companion = UserEntityCompanion(
|
||||
name: Value(user.name),
|
||||
email: Value(user.email),
|
||||
);
|
||||
|
||||
batch.insert(
|
||||
_db.userEntity,
|
||||
companion.copyWith(id: Value(user.id.toUuidByte())),
|
||||
onConflict: DoUpdate((_) => companion),
|
||||
);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
} catch (e, s) {
|
||||
_logger.severe('Error while processing SyncUserV1', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> deletePartnerV1(Iterable<SyncPartnerDeleteV1> data) async {
|
||||
try {
|
||||
await _db.batch((batch) {
|
||||
for (final partner in data) {
|
||||
batch.delete(
|
||||
_db.partnerEntity,
|
||||
PartnerEntityCompanion(
|
||||
sharedById: Value(partner.sharedById.toUuidByte()),
|
||||
sharedWithId: Value(partner.sharedWithId.toUuidByte()),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
} catch (e, s) {
|
||||
_logger.severe('Error while processing SyncPartnerDeleteV1', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> updatePartnerV1(Iterable<SyncPartnerV1> data) async {
|
||||
try {
|
||||
await _db.batch((batch) {
|
||||
for (final partner in data) {
|
||||
final companion =
|
||||
PartnerEntityCompanion(inTimeline: Value(partner.inTimeline));
|
||||
|
||||
batch.insert(
|
||||
_db.partnerEntity,
|
||||
companion.copyWith(
|
||||
sharedById: Value(partner.sharedById.toUuidByte()),
|
||||
sharedWithId: Value(partner.sharedWithId.toUuidByte()),
|
||||
),
|
||||
onConflict: DoUpdate((_) => companion),
|
||||
);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
} catch (e, s) {
|
||||
_logger.severe('Error while processing SyncPartnerV1', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import 'package:flutter_displaymode/flutter_displaymode.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/locales.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/generated/codegen_loader.g.dart';
|
||||
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
@@ -31,13 +32,15 @@ import 'package:immich_mobile/utils/migration.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:timezone/data/latest.dart';
|
||||
import 'package:immich_mobile/generated/codegen_loader.g.dart';
|
||||
import 'package:worker_manager/worker_manager.dart';
|
||||
|
||||
void main() async {
|
||||
ImmichWidgetsBinding();
|
||||
final db = await Bootstrap.initIsar();
|
||||
await Bootstrap.initDomain(db);
|
||||
await initApp();
|
||||
// Warm-up isolate pool for worker manager
|
||||
await workerManager.init(dynamicSpawning: true);
|
||||
await migrateDatabaseIfNeeded(db);
|
||||
HttpOverrides.global = HttpSSLCertOverride();
|
||||
|
||||
|
||||
8
mobile/lib/providers/background_sync.provider.dart
Normal file
8
mobile/lib/providers/background_sync.provider.dart
Normal file
@@ -0,0 +1,8 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
||||
|
||||
final backgroundSyncProvider = Provider<BackgroundSyncManager>((ref) {
|
||||
final manager = BackgroundSyncManager();
|
||||
ref.onDispose(manager.cancel);
|
||||
return manager;
|
||||
});
|
||||
12
mobile/lib/providers/infrastructure/cancel.provider.dart
Normal file
12
mobile/lib/providers/infrastructure/cancel.provider.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
/// Provider holding a boolean function that returns true when cancellation is requested.
|
||||
/// A computation running in the isolate uses the function to implement cooperative cancellation.
|
||||
final cancellationProvider = Provider<bool Function()>(
|
||||
// This will be overridden in the isolate's container.
|
||||
// Throwing ensures it's not used without an override.
|
||||
(ref) => throw UnimplementedError(
|
||||
"cancellationProvider must be overridden in the isolate's ProviderContainer and not to be used in the root isolate",
|
||||
),
|
||||
name: 'cancellationProvider',
|
||||
);
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
@@ -6,3 +9,9 @@ part 'db.provider.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
Isar isar(Ref ref) => throw UnimplementedError('isar');
|
||||
|
||||
final driftProvider = Provider<Drift>((ref) {
|
||||
final drift = Drift();
|
||||
ref.onDispose(() => unawaited(drift.close()));
|
||||
return drift;
|
||||
});
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/services/sync_stream.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
|
||||
final syncStreamServiceProvider = Provider(
|
||||
(ref) {
|
||||
final instance = SyncStreamService(
|
||||
ref.watch(syncApiRepositoryProvider),
|
||||
);
|
||||
|
||||
ref.onDispose(() => unawaited(instance.dispose()));
|
||||
|
||||
return instance;
|
||||
},
|
||||
(ref) => SyncStreamService(
|
||||
syncApiRepository: ref.watch(syncApiRepositoryProvider),
|
||||
syncStreamRepository: ref.watch(syncStreamRepositoryProvider),
|
||||
cancelChecker: ref.watch(cancellationProvider),
|
||||
),
|
||||
);
|
||||
|
||||
final syncApiRepositoryProvider = Provider(
|
||||
(ref) => SyncApiRepository(
|
||||
ref.watch(apiServiceProvider),
|
||||
),
|
||||
(ref) => SyncApiRepository(ref.watch(apiServiceProvider)),
|
||||
);
|
||||
|
||||
final syncStreamRepositoryProvider = Provider(
|
||||
(ref) => DriftSyncStreamRepository(ref.watch(driftProvider)),
|
||||
);
|
||||
|
||||
@@ -3,12 +3,14 @@ import 'dart:io';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/interfaces/auth.interface.dart';
|
||||
import 'package:immich_mobile/interfaces/auth_api.interface.dart';
|
||||
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
|
||||
import 'package:immich_mobile/models/auth/login_response.model.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/repositories/auth.repository.dart';
|
||||
import 'package:immich_mobile/repositories/auth_api.repository.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
@@ -22,6 +24,7 @@ final authServiceProvider = Provider(
|
||||
ref.watch(authRepositoryProvider),
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(networkServiceProvider),
|
||||
ref.watch(backgroundSyncProvider),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -30,6 +33,7 @@ class AuthService {
|
||||
final IAuthRepository _authRepository;
|
||||
final ApiService _apiService;
|
||||
final NetworkService _networkService;
|
||||
final BackgroundSyncManager _backgroundSyncManager;
|
||||
|
||||
final _log = Logger("AuthService");
|
||||
|
||||
@@ -38,6 +42,7 @@ class AuthService {
|
||||
this._authRepository,
|
||||
this._apiService,
|
||||
this._networkService,
|
||||
this._backgroundSyncManager,
|
||||
);
|
||||
|
||||
/// Validates the provided server URL by resolving and setting the endpoint.
|
||||
@@ -115,8 +120,10 @@ class AuthService {
|
||||
/// - Asset ETag
|
||||
///
|
||||
/// All deletions are executed in parallel using [Future.wait].
|
||||
Future<void> clearLocalData() {
|
||||
return Future.wait([
|
||||
Future<void> clearLocalData() async {
|
||||
// Cancel any ongoing background sync operations before clearing data
|
||||
await _backgroundSyncManager.cancel();
|
||||
await Future.wait([
|
||||
_authRepository.clearLocalData(),
|
||||
Store.delete(StoreKey.currentUser),
|
||||
Store.delete(StoreKey.accessToken),
|
||||
|
||||
@@ -48,11 +48,15 @@ abstract final class Bootstrap {
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> initDomain(Isar db) async {
|
||||
static Future<void> initDomain(
|
||||
Isar db, {
|
||||
bool shouldBufferLogs = true,
|
||||
}) async {
|
||||
await StoreService.init(storeRepository: IsarStoreRepository(db));
|
||||
await LogService.init(
|
||||
logRepository: IsarLogRepository(db),
|
||||
storeRepository: IsarStoreRepository(db),
|
||||
shouldBuffer: shouldBufferLogs,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
69
mobile/lib/utils/isolate.dart
Normal file
69
mobile/lib/utils/isolate.dart
Normal file
@@ -0,0 +1,69 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/utils/bootstrap.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:worker_manager/worker_manager.dart';
|
||||
|
||||
class InvalidIsolateUsageException implements Exception {
|
||||
const InvalidIsolateUsageException();
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
"IsolateHelper should only be used from the root isolate";
|
||||
}
|
||||
|
||||
// !! Should be used only from the root isolate
|
||||
Cancelable<T?> runInIsolateGentle<T>({
|
||||
required Future<T> Function(ProviderContainer ref) computation,
|
||||
String? debugLabel,
|
||||
}) {
|
||||
final token = RootIsolateToken.instance;
|
||||
if (token == null) {
|
||||
throw const InvalidIsolateUsageException();
|
||||
}
|
||||
|
||||
return workerManager.executeGentle((cancelledChecker) async {
|
||||
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
|
||||
DartPluginRegistrant.ensureInitialized();
|
||||
|
||||
final db = await Bootstrap.initIsar();
|
||||
await Bootstrap.initDomain(db, shouldBufferLogs: false);
|
||||
final ref = ProviderContainer(
|
||||
overrides: [
|
||||
// TODO: Remove once isar is removed
|
||||
dbProvider.overrideWithValue(db),
|
||||
isarProvider.overrideWithValue(db),
|
||||
cancellationProvider.overrideWithValue(cancelledChecker),
|
||||
],
|
||||
);
|
||||
|
||||
Logger log = Logger("IsolateLogger");
|
||||
|
||||
try {
|
||||
return await computation(ref);
|
||||
} on CanceledError {
|
||||
log.warning(
|
||||
"Computation cancelled ${debugLabel == null ? '' : ' for $debugLabel'}",
|
||||
);
|
||||
} catch (error, stack) {
|
||||
log.severe(
|
||||
"Error in runInIsolateGentle ${debugLabel == null ? '' : ' for $debugLabel'}",
|
||||
error,
|
||||
stack,
|
||||
);
|
||||
} finally {
|
||||
// Wait for the logs to flush
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
// Always close the new db connection on Isolate end
|
||||
ref.read(driftProvider).close();
|
||||
ref.read(isarProvider).close();
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user