feature(mobile): sync assets, albums & users to local database on device (#1759)

* feature(mobile): sync assets, albums & users to local database on device

* try to fix tests

* move DB sync operations to new SyncService

* clear db on user logout

* fix reason for endless loading timeline

* fix error when deleting album

* fix thumbnail of device albums

* add a few comments

* fix Hive box not open in album service when loading local assets

* adjust tests to int IDs

* fix bug: show all albums when Recent is selected

* update generated api

* reworked Recents album isAll handling

* guard against wrongly interleaved sync operations

* fix: timeline asset ordering (sort asset state by created at)

* fix: sort assets in albums by created at
This commit is contained in:
Fynn Petersen-Frey
2023-03-03 23:38:30 +01:00
committed by GitHub
parent 8f11529a75
commit 8708867c1c
61 changed files with 9024 additions and 893 deletions

View File

@@ -1,132 +1,153 @@
import 'package:flutter/cupertino.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:isar/isar.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
part 'album.g.dart';
@Collection(inheritance: false)
class Album {
Album.remote(AlbumResponseDto dto)
: remoteId = dto.id,
name = dto.albumName,
createdAt = DateTime.parse(dto.createdAt),
// TODO add modifiedAt to server
modifiedAt = DateTime.parse(dto.createdAt),
shared = dto.shared,
ownerId = dto.ownerId,
albumThumbnailAssetId = dto.albumThumbnailAssetId,
assetCount = dto.assetCount,
sharedUsers = dto.sharedUsers.map((e) => User.fromDto(e)).toList(),
assets = dto.assets.map(Asset.remote).toList();
@protected
Album({
this.remoteId,
this.localId,
required this.name,
required this.ownerId,
required this.createdAt,
required this.modifiedAt,
required this.shared,
required this.assetCount,
this.albumThumbnailAssetId,
this.sharedUsers = const [],
this.assets = const [],
});
Id id = Isar.autoIncrement;
@Index(unique: false, replace: false, type: IndexType.hash)
String? remoteId;
@Index(unique: false, replace: false, type: IndexType.hash)
String? localId;
String name;
String ownerId;
DateTime createdAt;
DateTime modifiedAt;
bool shared;
String? albumThumbnailAssetId;
int assetCount;
List<User> sharedUsers = const [];
List<Asset> assets = const [];
final IsarLink<User> owner = IsarLink<User>();
final IsarLink<Asset> thumbnail = IsarLink<Asset>();
final IsarLinks<User> sharedUsers = IsarLinks<User>();
final IsarLinks<Asset> assets = IsarLinks<Asset>();
List<Asset> _sortedAssets = [];
@ignore
List<Asset> get sortedAssets => _sortedAssets;
@ignore
bool get isRemote => remoteId != null;
@ignore
bool get isLocal => localId != null;
String get id => isRemote ? remoteId! : localId!;
@ignore
int get assetCount => assets.length;
@ignore
String? get ownerId => owner.value?.id;
Future<void> loadSortedAssets() async {
_sortedAssets = await assets.filter().sortByFileCreatedAt().findAll();
}
@override
bool operator ==(other) {
if (other is! Album) return false;
return remoteId == other.remoteId &&
return id == other.id &&
remoteId == other.remoteId &&
localId == other.localId &&
name == other.name &&
createdAt == other.createdAt &&
modifiedAt == other.modifiedAt &&
shared == other.shared &&
ownerId == other.ownerId &&
albumThumbnailAssetId == other.albumThumbnailAssetId;
owner.value == other.owner.value &&
thumbnail.value == other.thumbnail.value &&
sharedUsers.length == other.sharedUsers.length &&
assets.length == other.assets.length;
}
@override
@ignore
int get hashCode =>
id.hashCode ^
remoteId.hashCode ^
localId.hashCode ^
name.hashCode ^
createdAt.hashCode ^
modifiedAt.hashCode ^
shared.hashCode ^
ownerId.hashCode ^
albumThumbnailAssetId.hashCode;
owner.value.hashCode ^
thumbnail.value.hashCode ^
sharedUsers.length.hashCode ^
assets.length.hashCode;
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json["remoteId"] = remoteId;
json["localId"] = localId;
json["name"] = name;
json["ownerId"] = ownerId;
json["createdAt"] = createdAt.millisecondsSinceEpoch;
json["modifiedAt"] = modifiedAt.millisecondsSinceEpoch;
json["shared"] = shared;
json["albumThumbnailAssetId"] = albumThumbnailAssetId;
json["assetCount"] = assetCount;
json["sharedUsers"] = sharedUsers;
json["assets"] = assets;
return json;
static Album local(AssetPathEntity ape) {
final Album a = Album(
name: ape.name,
createdAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(),
modifiedAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(),
shared: false,
);
a.owner.value = Store.get(StoreKey.currentUser);
a.localId = ape.id;
return a;
}
static Album? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return Album(
remoteId: json["remoteId"],
localId: json["localId"],
name: json["name"],
ownerId: json["ownerId"],
createdAt: DateTime.fromMillisecondsSinceEpoch(
json["createdAt"],
isUtc: true,
),
modifiedAt: DateTime.fromMillisecondsSinceEpoch(
json["modifiedAt"],
isUtc: true,
),
shared: json["shared"],
albumThumbnailAssetId: json["albumThumbnailAssetId"],
assetCount: json["assetCount"],
sharedUsers: _listFromJson<User>(json["sharedUsers"], User.fromJson),
assets: _listFromJson<Asset>(json["assets"], Asset.fromJson),
);
static Future<Album> remote(AlbumResponseDto dto) async {
final Isar db = Isar.getInstance()!;
final Album a = Album(
remoteId: dto.id,
name: dto.albumName,
createdAt: DateTime.parse(dto.createdAt),
modifiedAt: DateTime.parse(dto.updatedAt),
shared: dto.shared,
);
a.owner.value = await db.users.getById(dto.ownerId);
if (dto.albumThumbnailAssetId != null) {
a.thumbnail.value = await db.assets
.where()
.remoteIdEqualTo(dto.albumThumbnailAssetId)
.findFirst();
}
return null;
if (dto.sharedUsers.isNotEmpty) {
final users = await db.users
.getAllById(dto.sharedUsers.map((e) => e.id).toList(growable: false));
a.sharedUsers.addAll(users.cast());
}
if (dto.assets.isNotEmpty) {
final assets =
await db.assets.getAllByRemoteId(dto.assets.map((e) => e.id));
a.assets.addAll(assets);
}
return a;
}
}
List<T> _listFromJson<T>(
dynamic json,
T? Function(dynamic) fromJson,
) {
final result = <T>[];
if (json is List && json.isNotEmpty) {
for (final entry in json) {
final value = fromJson(entry);
if (value != null) {
result.add(value);
}
}
extension AssetsHelper on IsarCollection<Album> {
Future<void> store(Album a) async {
await put(a);
await a.owner.save();
await a.thumbnail.save();
await a.sharedUsers.save();
await a.assets.save();
}
return result;
}
extension AssetPathEntityHelper on AssetPathEntity {
Future<List<Asset>> getAssets({
int start = 0,
int end = 0x7fffffffffffffff,
}) async {
final assetEntities = await getAssetListRange(start: start, end: end);
return assetEntities.map(Asset.local).toList();
}
}
extension AlbumResponseDtoHelper on AlbumResponseDto {
List<Asset> getAssets() => assets.map(Asset.remote).toList();
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,60 +1,65 @@
import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:isar/isar.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/utils/builtin_extensions.dart';
import 'package:path/path.dart' as p;
part 'asset.g.dart';
/// Asset (online or local)
@Collection(inheritance: false)
class Asset {
Asset.remote(AssetResponseDto remote)
: remoteId = remote.id,
fileCreatedAt = DateTime.parse(remote.fileCreatedAt),
fileModifiedAt = DateTime.parse(remote.fileModifiedAt),
isLocal = false,
fileCreatedAt = DateTime.parse(remote.fileCreatedAt).toUtc(),
fileModifiedAt = DateTime.parse(remote.fileModifiedAt).toUtc(),
updatedAt = DateTime.parse(remote.updatedAt).toUtc(),
durationInSeconds = remote.duration.toDuration().inSeconds,
fileName = p.basename(remote.originalPath),
height = remote.exifInfo?.exifImageHeight?.toInt(),
width = remote.exifInfo?.exifImageWidth?.toInt(),
livePhotoVideoId = remote.livePhotoVideoId,
deviceAssetId = remote.deviceAssetId,
deviceId = remote.deviceId,
ownerId = remote.ownerId,
latitude = remote.exifInfo?.latitude?.toDouble(),
longitude = remote.exifInfo?.longitude?.toDouble(),
localId = remote.deviceAssetId,
deviceId = fastHash(remote.deviceId),
ownerId = fastHash(remote.ownerId),
exifInfo =
remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null,
isFavorite = remote.isFavorite;
Asset.local(AssetEntity local, String owner)
Asset.local(AssetEntity local)
: localId = local.id,
latitude = local.latitude,
longitude = local.longitude,
isLocal = true,
durationInSeconds = local.duration,
height = local.height,
width = local.width,
fileName = local.title!,
deviceAssetId = local.id,
deviceId = Hive.box(userInfoBox).get(deviceIdKey),
ownerId = owner,
deviceId = Store.get(StoreKey.deviceIdHash),
ownerId = Store.get<User>(StoreKey.currentUser)!.isarId,
fileModifiedAt = local.modifiedDateTime.toUtc(),
updatedAt = local.modifiedDateTime.toUtc(),
isFavorite = local.isFavorite,
fileCreatedAt = local.createDateTime.toUtc() {
if (fileCreatedAt.year == 1970) {
fileCreatedAt = fileModifiedAt;
}
if (local.latitude != null) {
exifInfo = ExifInfo(lat: local.latitude, long: local.longitude);
}
}
Asset({
this.localId,
this.remoteId,
required this.deviceAssetId,
required this.localId,
required this.deviceId,
required this.ownerId,
required this.fileCreatedAt,
required this.fileModifiedAt,
this.latitude,
this.longitude,
required this.updatedAt,
required this.durationInSeconds,
this.width,
this.height,
@@ -62,21 +67,22 @@ class Asset {
this.livePhotoVideoId,
this.exifInfo,
required this.isFavorite,
required this.isLocal,
});
@ignore
AssetEntity? _local;
@ignore
AssetEntity? get local {
if (isLocal && _local == null) {
_local = AssetEntity(
id: localId!.toString(),
id: localId.toString(),
typeInt: isImage ? 1 : 2,
width: width!,
height: height!,
duration: durationInSeconds,
createDateSecond: fileCreatedAt.millisecondsSinceEpoch ~/ 1000,
latitude: latitude,
longitude: longitude,
modifiedDateSecond: fileModifiedAt.millisecondsSinceEpoch ~/ 1000,
title: fileName,
);
@@ -84,110 +90,136 @@ class Asset {
return _local;
}
String? localId;
Id id = Isar.autoIncrement;
@Index(unique: false, replace: false, type: IndexType.hash)
String? remoteId;
String deviceAssetId;
@Index(
unique: true,
replace: false,
type: IndexType.hash,
composite: [CompositeIndex('deviceId')],
)
String localId;
String deviceId;
int deviceId;
String ownerId;
int ownerId;
DateTime fileCreatedAt;
DateTime fileModifiedAt;
double? latitude;
double? longitude;
DateTime updatedAt;
int durationInSeconds;
int? width;
short? width;
int? height;
short? height;
String fileName;
String? livePhotoVideoId;
ExifInfo? exifInfo;
bool isFavorite;
String get id => isLocal ? localId.toString() : remoteId!;
bool isLocal;
@ignore
ExifInfo? exifInfo;
@ignore
bool get isInDb => id != Isar.autoIncrement;
@ignore
String get name => p.withoutExtension(fileName);
@ignore
bool get isRemote => remoteId != null;
bool get isLocal => localId != null;
@ignore
bool get isImage => durationInSeconds == 0;
@ignore
Duration get duration => Duration(seconds: durationInSeconds);
@override
bool operator ==(other) {
if (other is! Asset) return false;
return id == other.id && isLocal == other.isLocal;
return id == other.id;
}
@override
@ignore
int get hashCode => id.hashCode;
// methods below are only required for caching as JSON
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json["localId"] = localId;
json["remoteId"] = remoteId;
json["deviceAssetId"] = deviceAssetId;
json["deviceId"] = deviceId;
json["ownerId"] = ownerId;
json["fileCreatedAt"] = fileCreatedAt.millisecondsSinceEpoch;
json["fileModifiedAt"] = fileModifiedAt.millisecondsSinceEpoch;
json["latitude"] = latitude;
json["longitude"] = longitude;
json["durationInSeconds"] = durationInSeconds;
json["width"] = width;
json["height"] = height;
json["fileName"] = fileName;
json["livePhotoVideoId"] = livePhotoVideoId;
json["isFavorite"] = isFavorite;
if (exifInfo != null) {
json["exifInfo"] = exifInfo!.toJson();
bool updateFromAssetEntity(AssetEntity ae) {
// TODO check more fields;
// width and height are most important because local assets require these
final bool hasChanges =
isLocal == false || width != ae.width || height != ae.height;
if (hasChanges) {
isLocal = true;
width = ae.width;
height = ae.height;
}
return json;
return hasChanges;
}
static Asset? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return Asset(
localId: json["localId"],
remoteId: json["remoteId"],
deviceAssetId: json["deviceAssetId"],
deviceId: json["deviceId"],
ownerId: json["ownerId"],
fileCreatedAt:
DateTime.fromMillisecondsSinceEpoch(json["fileCreatedAt"], isUtc: true),
fileModifiedAt: DateTime.fromMillisecondsSinceEpoch(
json["fileModifiedAt"],
isUtc: true,
),
latitude: json["latitude"],
longitude: json["longitude"],
durationInSeconds: json["durationInSeconds"],
width: json["width"],
height: json["height"],
fileName: json["fileName"],
livePhotoVideoId: json["livePhotoVideoId"],
exifInfo: ExifInfo.fromJson(json["exifInfo"]),
isFavorite: json["isFavorite"],
);
Asset withUpdatesFromDto(AssetResponseDto dto) =>
Asset.remote(dto).updateFromDb(this);
Asset updateFromDb(Asset a) {
assert(localId == a.localId);
assert(deviceId == a.deviceId);
id = a.id;
isLocal |= a.isLocal;
remoteId ??= a.remoteId;
width ??= a.width;
height ??= a.height;
exifInfo ??= a.exifInfo;
exifInfo?.id = id;
return this;
}
Future<void> put(Isar db) async {
await db.assets.put(this);
if (exifInfo != null) {
exifInfo!.id = id;
await db.exifInfos.put(exifInfo!);
}
return null;
}
static int compareByDeviceIdLocalId(Asset a, Asset b) {
final int order = a.deviceId.compareTo(b.deviceId);
return order == 0 ? a.localId.compareTo(b.localId) : order;
}
static int compareById(Asset a, Asset b) => a.id.compareTo(b.id);
static int compareByLocalId(Asset a, Asset b) =>
a.localId.compareTo(b.localId);
}
extension AssetsHelper on IsarCollection<Asset> {
Future<int> deleteAllByRemoteId(Iterable<String> ids) =>
ids.isEmpty ? Future.value(0) : _remote(ids).deleteAll();
Future<int> deleteAllByLocalId(Iterable<String> ids) =>
ids.isEmpty ? Future.value(0) : _local(ids).deleteAll();
Future<List<Asset>> getAllByRemoteId(Iterable<String> ids) =>
ids.isEmpty ? Future.value([]) : _remote(ids).findAll();
Future<List<Asset>> getAllByLocalId(Iterable<String> ids) =>
ids.isEmpty ? Future.value([]) : _local(ids).findAll();
QueryBuilder<Asset, Asset, QAfterWhereClause> _remote(Iterable<String> ids) =>
where().anyOf(ids, (q, String e) => q.remoteIdEqualTo(e));
QueryBuilder<Asset, Asset, QAfterWhereClause> _local(Iterable<String> ids) {
return where().anyOf(
ids,
(q, String e) =>
q.localIdDeviceIdEqualTo(e, Store.get(StoreKey.deviceIdHash)),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,86 +1,93 @@
import 'package:isar/isar.dart';
import 'package:openapi/api.dart';
import 'package:immich_mobile/utils/builtin_extensions.dart';
part 'exif_info.g.dart';
/// Exif information 1:1 relation with Asset
@Collection(inheritance: false)
class ExifInfo {
Id? id;
int? fileSize;
String? make;
String? model;
String? orientation;
String? lensModel;
double? fNumber;
double? focalLength;
int? iso;
double? exposureTime;
String? lens;
float? f;
float? mm;
short? iso;
float? exposureSeconds;
float? lat;
float? long;
String? city;
String? state;
String? country;
@ignore
String get exposureTime {
if (exposureSeconds == null) {
return "";
} else if (exposureSeconds! < 1) {
return "1/${(1.0 / exposureSeconds!).round()} s";
} else {
return "${exposureSeconds!.toStringAsFixed(1)} s";
}
}
@ignore
String get fNumber => f != null ? f!.toStringAsFixed(1) : "";
@ignore
String get focalLength => mm != null ? mm!.toStringAsFixed(1) : "";
@ignore
double? get latitude => lat;
@ignore
double? get longitude => long;
ExifInfo.fromDto(ExifResponseDto dto)
: fileSize = dto.fileSizeInByte,
make = dto.make,
model = dto.model,
orientation = dto.orientation,
lensModel = dto.lensModel,
fNumber = dto.fNumber?.toDouble(),
focalLength = dto.focalLength?.toDouble(),
lens = dto.lensModel,
f = dto.fNumber?.toDouble(),
mm = dto.focalLength?.toDouble(),
iso = dto.iso?.toInt(),
exposureTime = dto.exposureTime?.toDouble(),
exposureSeconds = _exposureTimeToSeconds(dto.exposureTime),
lat = dto.latitude?.toDouble(),
long = dto.longitude?.toDouble(),
city = dto.city,
state = dto.state,
country = dto.country;
// stuff below is only required for caching as JSON
ExifInfo(
ExifInfo({
this.fileSize,
this.make,
this.model,
this.orientation,
this.lensModel,
this.fNumber,
this.focalLength,
this.lens,
this.f,
this.mm,
this.iso,
this.exposureTime,
this.exposureSeconds,
this.lat,
this.long,
this.city,
this.state,
this.country,
);
});
}
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json["fileSize"] = fileSize;
json["make"] = make;
json["model"] = model;
json["orientation"] = orientation;
json["lensModel"] = lensModel;
json["fNumber"] = fNumber;
json["focalLength"] = focalLength;
json["iso"] = iso;
json["exposureTime"] = exposureTime;
json["city"] = city;
json["state"] = state;
json["country"] = country;
return json;
}
static ExifInfo? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return ExifInfo(
json["fileSize"],
json["make"],
json["model"],
json["orientation"],
json["lensModel"],
json["fNumber"],
json["focalLength"],
json["iso"],
json["exposureTime"],
json["city"],
json["state"],
json["country"],
);
}
double? _exposureTimeToSeconds(String? s) {
if (s == null) {
return null;
}
double? value = double.tryParse(s);
if (value != null) {
return value;
}
final parts = s.split("/");
if (parts.length == 2) {
return parts[0].toDouble() / parts[1].toDouble();
}
return null;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
import 'package:immich_mobile/shared/models/user.dart';
import 'package:isar/isar.dart';
import 'dart:convert';
@@ -25,26 +26,28 @@ class Store {
/// Returns the stored value for the given key, or the default value if null
static T? get<T>(StoreKey key, [T? defaultValue]) =>
_cache[key._id] ?? defaultValue;
_cache[key.id] ?? defaultValue;
/// Stores the value synchronously in the cache and asynchronously in the DB
static Future<void> put<T>(StoreKey key, T value) {
_cache[key._id] = value;
return _db.writeTxn(() => _db.storeValues.put(StoreValue._of(value, key)));
_cache[key.id] = value;
return _db.writeTxn(
() async => _db.storeValues.put(await StoreValue._of(value, key)),
);
}
/// Removes the value synchronously from the cache and asynchronously from the DB
static Future<void> delete(StoreKey key) {
_cache[key._id] = null;
return _db.writeTxn(() => _db.storeValues.delete(key._id));
_cache[key.id] = null;
return _db.writeTxn(() => _db.storeValues.delete(key.id));
}
/// Fills the cache with the values from the DB
static _populateCache() {
for (StoreKey key in StoreKey.values) {
final StoreValue? value = _db.storeValues.getSync(key._id);
final StoreValue? value = _db.storeValues.getSync(key.id);
if (value != null) {
_cache[key._id] = value._extract(key);
_cache[key.id] = value._extract(key);
}
}
}
@@ -67,17 +70,22 @@ class StoreValue {
int? intValue;
String? strValue;
T? _extract<T>(StoreKey key) => key._isInt
? intValue
: (key._fromJson != null
? key._fromJson!(json.decode(strValue!))
T? _extract<T>(StoreKey key) => key.isInt
? (key.fromDb == null ? intValue : key.fromDb!.call(Store._db, intValue!))
: (key.fromJson != null
? key.fromJson!(json.decode(strValue!))
: strValue);
static StoreValue _of(dynamic value, StoreKey key) => StoreValue(
key._id,
intValue: key._isInt ? value : null,
strValue: key._isInt
static Future<StoreValue> _of(dynamic value, StoreKey key) async =>
StoreValue(
key.id,
intValue: key.isInt
? (key.toDb == null
? value
: await key.toDb!.call(Store._db, value))
: null,
strValue: key.isInt
? null
: (key._fromJson == null ? value : json.encode(value.toJson())),
: (key.fromJson == null ? value : json.encode(value.toJson())),
);
}
@@ -86,11 +94,28 @@ class StoreValue {
enum StoreKey {
userRemoteId(0),
assetETag(1),
currentUser(2, isInt: true, fromDb: _getUser, toDb: _toUser),
deviceIdHash(3, isInt: true),
deviceId(4),
;
// ignore: unused_element
const StoreKey(this._id, [this._isInt = false, this._fromJson]);
final int _id;
final bool _isInt;
final Function(dynamic)? _fromJson;
const StoreKey(
this.id, {
this.isInt = false,
this.fromDb,
this.toDb,
// ignore: unused_element
this.fromJson,
});
final int id;
final bool isInt;
final dynamic Function(Isar, int)? fromDb;
final Future<int> Function(Isar, dynamic)? toDb;
final Function(dynamic)? fromJson;
}
User? _getUser(Isar db, int i) => db.users.getSync(i);
Future<int> _toUser(Isar db, dynamic u) {
User user = (u as User);
return db.users.put(user);
}

View File

@@ -1,94 +1,63 @@
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:isar/isar.dart';
import 'package:openapi/api.dart';
part 'user.g.dart';
@Collection(inheritance: false)
class User {
User({
required this.id,
required this.updatedAt,
required this.email,
required this.firstName,
required this.lastName,
required this.profileImagePath,
required this.isAdmin,
required this.oauthId,
});
Id get isarId => fastHash(id);
User.fromDto(UserResponseDto dto)
: id = dto.id,
updatedAt = dto.updatedAt != null
? DateTime.parse(dto.updatedAt!).toUtc()
: DateTime.now().toUtc(),
email = dto.email,
firstName = dto.firstName,
lastName = dto.lastName,
profileImagePath = dto.profileImagePath,
isAdmin = dto.isAdmin,
oauthId = dto.oauthId;
isAdmin = dto.isAdmin;
@Index(unique: true, replace: false, type: IndexType.hash)
String id;
DateTime updatedAt;
String email;
String firstName;
String lastName;
String profileImagePath;
bool isAdmin;
String oauthId;
@Backlink(to: 'owner')
final IsarLinks<Album> albums = IsarLinks<Album>();
@Backlink(to: 'sharedUsers')
final IsarLinks<Album> sharedAlbums = IsarLinks<Album>();
@override
bool operator ==(other) {
if (other is! User) return false;
return id == other.id &&
updatedAt == other.updatedAt &&
email == other.email &&
firstName == other.firstName &&
lastName == other.lastName &&
profileImagePath == other.profileImagePath &&
isAdmin == other.isAdmin &&
oauthId == other.oauthId;
isAdmin == other.isAdmin;
}
@override
@ignore
int get hashCode =>
id.hashCode ^
updatedAt.hashCode ^
email.hashCode ^
firstName.hashCode ^
lastName.hashCode ^
profileImagePath.hashCode ^
isAdmin.hashCode ^
oauthId.hashCode;
UserResponseDto toDto() {
return UserResponseDto(
id: id,
email: email,
firstName: firstName,
lastName: lastName,
profileImagePath: profileImagePath,
createdAt: '',
isAdmin: isAdmin,
shouldChangePassword: false,
oauthId: oauthId,
);
}
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json["id"] = id;
json["email"] = email;
json["firstName"] = firstName;
json["lastName"] = lastName;
json["profileImagePath"] = profileImagePath;
json["isAdmin"] = isAdmin;
json["oauthId"] = oauthId;
return json;
}
static User? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return User(
id: json["id"],
email: json["email"],
firstName: json["firstName"],
lastName: json["lastName"],
profileImagePath: json["profileImagePath"],
isAdmin: json["isAdmin"],
oauthId: json["oauthId"],
);
}
return null;
}
isAdmin.hashCode;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,19 @@
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/services/asset.service.dart';
import 'package:immich_mobile/shared/services/asset_cache.service.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/services/device_info.service.dart';
import 'package:collection/collection.dart';
import 'package:immich_mobile/utils/tuple.dart';
import 'package:intl/intl.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
@@ -50,50 +49,36 @@ class AssetsState {
}
}
class _CombineAssetsComputeParameters {
final Iterable<Asset> local;
final Iterable<Asset> remote;
final String deviceId;
_CombineAssetsComputeParameters(this.local, this.remote, this.deviceId);
}
class AssetNotifier extends StateNotifier<AssetsState> {
final AssetService _assetService;
final AssetCacheService _assetCacheService;
final AppSettingsService _settingsService;
final AlbumService _albumService;
final Isar _db;
final log = Logger('AssetNotifier');
final DeviceInfoService _deviceInfoService = DeviceInfoService();
bool _getAllAssetInProgress = false;
bool _deleteInProgress = false;
AssetNotifier(
this._assetService,
this._assetCacheService,
this._settingsService,
this._albumService,
this._db,
) : super(AssetsState.fromAssetList([]));
Future<void> _updateAssetsState(
List<Asset> newAssetList, {
bool cache = true,
}) async {
if (cache) {
_assetCacheService.put(newAssetList);
}
Future<void> _updateAssetsState(List<Asset> newAssetList) async {
final layout = AssetGridLayoutParameters(
_settingsService.getSetting(AppSettingsEnum.tilesPerRow),
_settingsService.getSetting(AppSettingsEnum.dynamicLayout),
GroupAssetsBy.values[_settingsService.getSetting(AppSettingsEnum.groupAssetsBy)],
GroupAssetsBy
.values[_settingsService.getSetting(AppSettingsEnum.groupAssetsBy)],
);
state = await AssetsState.fromAssetList(newAssetList)
.withRenderDataStructure(layout);
}
// Just a little helper to trigger a rebuild of the state object
Future<void> rebuildAssetGridDataStructure() async {
await _updateAssetsState(state.allAssets, cache: false);
await _updateAssetsState(state.allAssets);
}
getAllAsset() async {
@@ -104,127 +89,102 @@ class AssetNotifier extends StateNotifier<AssetsState> {
final stopwatch = Stopwatch();
try {
_getAllAssetInProgress = true;
bool isCacheValid = await _assetCacheService.isValid();
final User me = Store.get(StoreKey.currentUser);
final int cachedCount =
await _db.assets.filter().ownerIdEqualTo(me.isarId).count();
stopwatch.start();
if (isCacheValid && state.allAssets.isEmpty) {
final List<Asset>? cachedData = await _assetCacheService.get();
if (cachedData == null) {
isCacheValid = false;
log.warning("Cached asset data is invalid, fetching new data");
} else {
await _updateAssetsState(cachedData, cache: false);
log.info(
"Reading assets ${state.allAssets.length} from cache: ${stopwatch.elapsedMilliseconds}ms",
);
}
if (cachedCount > 0 && cachedCount != state.allAssets.length) {
await _updateAssetsState(await _getUserAssets(me.isarId));
log.info(
"Reading assets ${state.allAssets.length} from DB: ${stopwatch.elapsedMilliseconds}ms",
);
stopwatch.reset();
}
final localTask = _assetService.getLocalAssets(urgent: !isCacheValid);
final remoteTask = _assetService.getRemoteAssets(
etag: isCacheValid ? Store.get(StoreKey.assetETag) : null,
);
int remoteBegin = state.allAssets.indexWhere((a) => a.isRemote);
remoteBegin = remoteBegin == -1 ? state.allAssets.length : remoteBegin;
final List<Asset> currentLocal = state.allAssets.slice(0, remoteBegin);
final Pair<List<Asset>?, String?> remoteResult = await remoteTask;
List<Asset>? newRemote = remoteResult.first;
List<Asset>? newLocal = await localTask;
final bool newRemote = await _assetService.refreshRemoteAssets();
final bool newLocal = await _albumService.refreshDeviceAlbums();
log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();
if (newRemote == null &&
(newLocal == null || currentLocal.equals(newLocal))) {
if (!newRemote && !newLocal) {
log.info("state is already up-to-date");
return;
}
newRemote ??= state.allAssets.slice(remoteBegin);
newLocal ??= [];
final combinedAssets = await _combineLocalAndRemoteAssets(
local: newLocal,
remote: newRemote,
);
await _updateAssetsState(combinedAssets);
log.info("Combining assets: ${stopwatch.elapsedMilliseconds}ms");
Store.put(StoreKey.assetETag, remoteResult.second);
stopwatch.reset();
final assets = await _getUserAssets(me.isarId);
if (!const ListEquality().equals(assets, state.allAssets)) {
log.info("setting new asset state");
await _updateAssetsState(assets);
}
} finally {
_getAllAssetInProgress = false;
}
}
static Future<List<Asset>> _computeCombine(
_CombineAssetsComputeParameters data,
) async {
var local = data.local;
var remote = data.remote;
final deviceId = data.deviceId;
Future<List<Asset>> _getUserAssets(int userId) => _db.assets
.filter()
.ownerIdEqualTo(userId)
.sortByFileCreatedAtDesc()
.findAll();
final List<Asset> assets = [];
if (remote.isNotEmpty && local.isNotEmpty) {
final Set<String> existingIds = remote
.where((e) => e.deviceId == deviceId)
.map((e) => e.deviceAssetId)
.toSet();
local = local.where((e) => !existingIds.contains(e.id));
}
assets.addAll(local);
// the order (first all local, then remote assets) is important!
assets.addAll(remote);
return assets;
Future<void> clearAllAsset() {
state = AssetsState.empty();
return _db.writeTxn(() async {
await _db.assets.clear();
await _db.exifInfos.clear();
await _db.albums.clear();
});
}
Future<List<Asset>> _combineLocalAndRemoteAssets({
required Iterable<Asset> local,
required List<Asset> remote,
}) async {
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
return await compute(
_computeCombine,
_CombineAssetsComputeParameters(local, remote, deviceId),
);
}
clearAllAsset() {
_updateAssetsState([]);
}
void onNewAssetUploaded(Asset newAsset) {
Future<void> onNewAssetUploaded(Asset newAsset) async {
final int i = state.allAssets.indexWhere(
(a) =>
a.isRemote ||
(a.id == newAsset.deviceAssetId && a.deviceId == newAsset.deviceId),
(a.localId == newAsset.localId && a.deviceId == newAsset.deviceId),
);
if (i == -1 || state.allAssets[i].deviceAssetId != newAsset.deviceAssetId) {
_updateAssetsState([...state.allAssets, newAsset]);
if (i == -1 ||
state.allAssets[i].localId != newAsset.localId ||
state.allAssets[i].deviceId != newAsset.deviceId) {
await _updateAssetsState([...state.allAssets, newAsset]);
} else {
// unify local/remote assets by replacing the
// local-only asset in the DB with a local&remote asset
final Asset? inDb = await _db.assets
.where()
.localIdDeviceIdEqualTo(newAsset.localId, newAsset.deviceId)
.findFirst();
if (inDb != null) {
newAsset.id = inDb.id;
newAsset.isLocal = inDb.isLocal;
}
// order is important to keep all local-only assets at the beginning!
_updateAssetsState([
await _updateAssetsState([
...state.allAssets.slice(0, i),
...state.allAssets.slice(i + 1),
newAsset,
]);
// TODO here is a place to unify local/remote assets by replacing the
// local-only asset in the state with a local&remote asset
}
try {
await _db.writeTxn(() => newAsset.put(_db));
} on IsarError catch (e) {
debugPrint(e.toString());
}
}
deleteAssets(Set<Asset> deleteAssets) async {
Future<void> deleteAssets(Set<Asset> deleteAssets) async {
_deleteInProgress = true;
try {
_updateAssetsState(
state.allAssets.whereNot(deleteAssets.contains).toList(),
);
final localDeleted = await _deleteLocalAssets(deleteAssets);
final remoteDeleted = await _deleteRemoteAssets(deleteAssets);
final Set<String> deleted = HashSet();
deleted.addAll(localDeleted);
deleted.addAll(remoteDeleted);
if (deleted.isNotEmpty) {
_updateAssetsState(
state.allAssets.where((a) => !deleted.contains(a.id)).toList(),
);
if (localDeleted.isNotEmpty || remoteDeleted.isNotEmpty) {
final dbIds = deleteAssets.map((e) => e.id).toList();
await _db.writeTxn(() async {
await _db.exifInfos.deleteAll(dbIds);
await _db.assets.deleteAll(dbIds);
});
}
} finally {
_deleteInProgress = false;
@@ -232,16 +192,15 @@ class AssetNotifier extends StateNotifier<AssetsState> {
}
Future<List<String>> _deleteLocalAssets(Set<Asset> assetsToDelete) async {
var deviceInfo = await _deviceInfoService.getDeviceInfo();
var deviceId = deviceInfo["deviceId"];
final int deviceId = Store.get(StoreKey.deviceIdHash);
final List<String> local = [];
// Delete asset from device
for (final Asset asset in assetsToDelete) {
if (asset.isLocal) {
local.add(asset.localId!);
local.add(asset.localId);
} else if (asset.deviceId == deviceId) {
// Delete asset on device if it is still present
var localAsset = await AssetEntity.fromId(asset.deviceAssetId);
var localAsset = await AssetEntity.fromId(asset.localId);
if (localAsset != null) {
local.add(localAsset.id);
}
@@ -249,7 +208,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
}
if (local.isNotEmpty) {
try {
return await PhotoManager.editor.deleteWithIds(local);
await PhotoManager.editor.deleteWithIds(local);
} catch (e, stack) {
log.severe("Failed to delete asset from device", e, stack);
}
@@ -289,8 +248,9 @@ class AssetNotifier extends StateNotifier<AssetsState> {
final assetProvider = StateNotifierProvider<AssetNotifier, AssetsState>((ref) {
return AssetNotifier(
ref.watch(assetServiceProvider),
ref.watch(assetCacheServiceProvider),
ref.watch(appSettingsServiceProvider),
ref.watch(albumServiceProvider),
ref.watch(dbProvider),
);
});

View File

@@ -28,9 +28,13 @@ class ApiService {
debugPrint("Cannot init ApiServer endpoint, userInfoBox not open yet.");
}
}
String? _authToken;
setEndpoint(String endpoint) {
_apiClient = ApiClient(basePath: endpoint);
if (_authToken != null) {
setAccessToken(_authToken!);
}
userApi = UserApi(_apiClient);
authenticationApi = AuthenticationApi(_apiClient);
oAuthApi = OAuthApi(_apiClient);
@@ -94,6 +98,9 @@ class ApiService {
}
setAccessToken(String accessToken) {
_authToken = accessToken;
_apiClient.addDefaultHeader('Authorization', 'Bearer $accessToken');
}
ApiClient get apiClient => _apiClient;
}

View File

@@ -1,101 +1,84 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/shared/services/sync.service.dart';
import 'package:immich_mobile/utils/openapi_extensions.dart';
import 'package:immich_mobile/utils/tuple.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
final assetServiceProvider = Provider(
(ref) => AssetService(
ref.watch(apiServiceProvider),
ref.watch(backupServiceProvider),
ref.watch(backgroundServiceProvider),
ref.watch(syncServiceProvider),
ref.watch(dbProvider),
),
);
class AssetService {
final ApiService _apiService;
final BackupService _backupService;
final BackgroundService _backgroundService;
final SyncService _syncService;
final log = Logger('AssetService');
final Isar _db;
AssetService(this._apiService, this._backupService, this._backgroundService);
AssetService(
this._apiService,
this._syncService,
this._db,
);
/// Checks the server for updated assets and updates the local database if
/// required. Returns `true` if there were any changes.
Future<bool> refreshRemoteAssets() async {
final Stopwatch sw = Stopwatch()..start();
final int numOwnedRemoteAssets = await _db.assets
.where()
.remoteIdIsNotNull()
.filter()
.ownerIdEqualTo(Store.get<User>(StoreKey.currentUser)!.isarId)
.count();
final List<AssetResponseDto>? dtos =
await _getRemoteAssets(hasCache: numOwnedRemoteAssets > 0);
if (dtos == null) {
debugPrint("refreshRemoteAssets fast took ${sw.elapsedMilliseconds}ms");
return false;
}
final bool changes = await _syncService
.syncRemoteAssetsToDb(dtos.map(Asset.remote).toList());
debugPrint("refreshRemoteAssets full took ${sw.elapsedMilliseconds}ms");
return changes;
}
/// Returns `null` if the server state did not change, else list of assets
Future<Pair<List<Asset>?, String?>> getRemoteAssets({String? etag}) async {
Future<List<AssetResponseDto>?> _getRemoteAssets({
required bool hasCache,
}) async {
try {
// temporary fix for race condition that the _apiService
// get called before accessToken is set
var userInfoHiveBox = await Hive.openBox(userInfoBox);
var accessToken = userInfoHiveBox.get(accessTokenKey);
_apiService.setAccessToken(accessToken);
final etag = hasCache ? Store.get(StoreKey.assetETag) : null;
final Pair<List<AssetResponseDto>, String?>? remote =
await _apiService.assetApi.getAllAssetsWithETag(eTag: etag);
if (remote == null) {
return Pair(null, etag);
return null;
}
return Pair(
remote.first.map(Asset.remote).toList(growable: false),
remote.second,
);
if (remote.second != null && remote.second != etag) {
Store.put(StoreKey.assetETag, remote.second);
}
return remote.first;
} catch (e, stack) {
log.severe('Error while getting remote assets', e, stack);
debugPrint("[ERROR] [getRemoteAssets] $e");
return Pair(null, etag);
return null;
}
}
/// if [urgent] is `true`, do not block by waiting on the background service
/// to finish running. Returns `null` instead after a timeout.
Future<List<Asset>?> getLocalAssets({bool urgent = false}) async {
try {
final Future<bool> hasAccess = urgent
? _backgroundService.hasAccess
.timeout(const Duration(milliseconds: 250))
: _backgroundService.hasAccess;
if (!await hasAccess) {
throw Exception("Error [getAllAsset] failed to gain access");
}
final box = await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
final String userId = Store.get(StoreKey.userRemoteId);
if (backupAlbumInfo != null) {
return (await _backupService
.buildUploadCandidates(backupAlbumInfo.deepCopy()))
.map((e) => Asset.local(e, userId))
.toList(growable: false);
}
} catch (e, stackTrace) {
log.severe('Error while getting local assets', e, stackTrace);
debugPrint("Error [_getLocalAssets] ${e.toString()}");
}
return null;
}
Future<Asset?> getAssetById(String assetId) async {
try {
final dto = await _apiService.assetApi.getAssetById(assetId);
if (dto != null) {
return Asset.remote(dto);
}
} catch (e) {
debugPrint("Error [getAssetById] ${e.toString()}");
}
return null;
}
Future<List<DeleteAssetResponseDto>?> deleteAssets(
Iterable<Asset> deleteAssets,
) async {
@@ -114,6 +97,28 @@ class AssetService {
}
}
/// Loads the exif information from the database. If there is none, loads
/// the exif info from the server (remote assets only)
Future<Asset> loadExif(Asset a) async {
a.exifInfo ??= await _db.exifInfos.get(a.id);
if (a.exifInfo?.iso == null) {
if (a.isRemote) {
final dto = await _apiService.assetApi.getAssetById(a.remoteId!);
if (dto != null && dto.exifInfo != null) {
a = a.withUpdatesFromDto(dto);
if (a.isInDb) {
_db.writeTxn(() => a.put(_db));
} else {
debugPrint("[loadExif] parameter Asset is not from DB!");
}
}
} else {
// TODO implement local exif info parsing
}
}
return a;
}
Future<Asset?> updateAsset(
Asset asset,
UpdateAssetDto updateAssetDto,

View File

@@ -1,41 +1,13 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/services/json_cache.dart';
@Deprecated("only kept to remove its files after migration")
class AssetCacheService extends JsonCache<List<Asset>> {
AssetCacheService() : super("asset_cache");
static Future<List<Map<String, dynamic>>> _computeSerialize(
List<Asset> assets,
) async {
return assets.map((e) => e.toJson()).toList();
}
@override
void put(List<Asset> data) {}
@override
void put(List<Asset> data) async {
putRawData(await compute(_computeSerialize, data));
}
static Future<List<Asset>> _computeEncode(List<dynamic> data) async {
return data.map((e) => Asset.fromJson(e)).whereNotNull().toList();
}
@override
Future<List<Asset>?> get() async {
try {
final mapList = await readRawData() as List<dynamic>;
final responseData = await compute(_computeEncode, mapList);
return responseData;
} catch (e) {
debugPrint(e.toString());
await invalidate();
return null;
}
}
Future<List<Asset>?> get() => Future.value(null);
}
final assetCacheServiceProvider = Provider(
(ref) => AssetCacheService(),
);

View File

@@ -1,9 +1,8 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
@Deprecated("only kept to remove its files after migration")
abstract class JsonCache<T> {
final String cacheFileName;
@@ -32,33 +31,6 @@ abstract class JsonCache<T> {
}
}
static Future<String> _computeEncodeJson(dynamic toEncode) async {
return json.encode(toEncode);
}
Future<void> putRawData(dynamic data) async {
final jsonString = await compute(_computeEncodeJson, data);
final file = await _getCacheFile();
if (!await file.exists()) {
await file.create();
}
await file.writeAsString(jsonString);
}
static Future<dynamic> _computeDecodeJson(String jsonString) async {
return json.decode(jsonString);
}
Future<dynamic> readRawData() async {
final file = await _getCacheFile();
final data = await file.readAsString();
return await compute(_computeDecodeJson, data);
}
void put(T data);
Future<T?> get();
}

View File

@@ -0,0 +1,558 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:immich_mobile/utils/tuple.dart';
import 'package:isar/isar.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
final syncServiceProvider =
Provider((ref) => SyncService(ref.watch(dbProvider)));
class SyncService {
final Isar _db;
final AsyncMutex _lock = AsyncMutex();
SyncService(this._db);
// public methods:
/// Syncs users from the server to the local database
/// Returns `true`if there were any changes
Future<bool> syncUsersFromServer(List<User> users) async {
users.sortBy((u) => u.id);
final dbUsers = await _db.users.where().sortById().findAll();
final List<int> toDelete = [];
final List<User> toUpsert = [];
final changes = diffSortedListsSync(
users,
dbUsers,
compare: (User a, User b) => a.id.compareTo(b.id),
both: (User a, User b) {
if (a.updatedAt != b.updatedAt) {
toUpsert.add(a);
return true;
}
return false;
},
onlyFirst: (User a) => toUpsert.add(a),
onlySecond: (User b) => toDelete.add(b.isarId),
);
if (changes) {
await _db.writeTxn(() async {
await _db.users.deleteAll(toDelete);
await _db.users.putAll(toUpsert);
});
}
return changes;
}
/// Syncs remote assets owned by the logged-in user to the DB
/// Returns `true` if there were any changes
Future<bool> syncRemoteAssetsToDb(List<Asset> remote) =>
_lock.run(() => _syncRemoteAssetsToDb(remote));
/// Syncs remote albums to the database
/// returns `true` if there were any changes
Future<bool> syncRemoteAlbumsToDb(
List<AlbumResponseDto> remote, {
required bool isShared,
required FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
}) =>
_lock.run(() => _syncRemoteAlbumsToDb(remote, isShared, loadDetails));
/// Syncs all device albums and their assets to the database
/// Returns `true` if there were any changes
Future<bool> syncLocalAlbumAssetsToDb(List<AssetPathEntity> onDevice) =>
_lock.run(() => _syncLocalAlbumAssetsToDb(onDevice));
/// returns all Asset IDs that are not contained in the existing list
List<int> sharedAssetsToRemove(
List<Asset> deleteCandidates,
List<Asset> existing,
) {
if (deleteCandidates.isEmpty) {
return [];
}
deleteCandidates.sort(Asset.compareById);
existing.sort(Asset.compareById);
return _diffAssets(existing, deleteCandidates, compare: Asset.compareById)
.third
.map((e) => e.id)
.toList();
}
// private methods:
/// Syncs remote assets to the databas
/// returns `true` if there were any changes
Future<bool> _syncRemoteAssetsToDb(List<Asset> remote) async {
final User user = Store.get(StoreKey.currentUser);
final List<Asset> inDb = await _db.assets
.filter()
.ownerIdEqualTo(user.isarId)
.sortByDeviceId()
.thenByLocalId()
.findAll();
remote.sort(Asset.compareByDeviceIdLocalId);
final diff = _diffAssets(remote, inDb, remote: true);
if (diff.first.isEmpty && diff.second.isEmpty && diff.third.isEmpty) {
return false;
}
final idsToDelete = diff.third.map((e) => e.id).toList();
try {
await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete));
await _upsertAssetsWithExif(diff.first + diff.second);
} on IsarError catch (e) {
debugPrint(e.toString());
}
return true;
}
/// Syncs remote albums to the database
/// returns `true` if there were any changes
Future<bool> _syncRemoteAlbumsToDb(
List<AlbumResponseDto> remote,
bool isShared,
FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
) async {
remote.sortBy((e) => e.id);
final baseQuery = _db.albums.where().remoteIdIsNotNull().filter();
final QueryBuilder<Album, Album, QAfterFilterCondition> query;
if (isShared) {
query = baseQuery.sharedEqualTo(true);
} else {
final User me = Store.get(StoreKey.currentUser);
query = baseQuery.owner((q) => q.isarIdEqualTo(me.isarId));
}
final List<Album> dbAlbums = await query.sortByRemoteId().findAll();
final List<Asset> toDelete = [];
final List<Asset> existing = [];
final bool changes = await diffSortedLists(
remote,
dbAlbums,
compare: (AlbumResponseDto a, Album b) => a.id.compareTo(b.remoteId!),
both: (AlbumResponseDto a, Album b) =>
_syncRemoteAlbum(a, b, toDelete, existing, loadDetails),
onlyFirst: (AlbumResponseDto a) =>
_addAlbumFromServer(a, existing, loadDetails),
onlySecond: (Album a) => _removeAlbumFromDb(a, toDelete),
);
if (isShared && toDelete.isNotEmpty) {
final List<int> idsToRemove = sharedAssetsToRemove(toDelete, existing);
if (idsToRemove.isNotEmpty) {
await _db.writeTxn(() => _db.assets.deleteAll(idsToRemove));
}
} else {
assert(toDelete.isEmpty);
}
return changes;
}
/// syncs albums from the server to the local database (does not support
/// syncing changes from local back to server)
/// accumulates
Future<bool> _syncRemoteAlbum(
AlbumResponseDto dto,
Album album,
List<Asset> deleteCandidates,
List<Asset> existing,
FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
) async {
if (!_hasAlbumResponseDtoChanged(dto, album)) {
return false;
}
dto = await loadDetails(dto);
if (dto.assetCount != dto.assets.length) {
return false;
}
final assetsInDb =
await album.assets.filter().sortByDeviceId().thenByLocalId().findAll();
final List<Asset> assetsOnRemote = dto.getAssets();
assetsOnRemote.sort(Asset.compareByDeviceIdLocalId);
final d = _diffAssets(assetsOnRemote, assetsInDb);
final List<Asset> toAdd = d.first, toUpdate = d.second, toUnlink = d.third;
// update shared users
final List<User> sharedUsers = album.sharedUsers.toList(growable: false);
sharedUsers.sort((a, b) => a.id.compareTo(b.id));
dto.sharedUsers.sort((a, b) => a.id.compareTo(b.id));
final List<String> userIdsToAdd = [];
final List<User> usersToUnlink = [];
diffSortedListsSync(
dto.sharedUsers,
sharedUsers,
compare: (UserResponseDto a, User b) => a.id.compareTo(b.id),
both: (a, b) => false,
onlyFirst: (UserResponseDto a) => userIdsToAdd.add(a.id),
onlySecond: (User a) => usersToUnlink.add(a),
);
// for shared album: put missing album assets into local DB
final resultPair = await _linkWithExistingFromDb(toAdd);
await _upsertAssetsWithExif(resultPair.second);
final assetsToLink = resultPair.first + resultPair.second;
final usersToLink = (await _db.users.getAllById(userIdsToAdd)).cast<User>();
album.name = dto.albumName;
album.shared = dto.shared;
album.modifiedAt = DateTime.parse(dto.updatedAt).toUtc();
if (album.thumbnail.value?.remoteId != dto.albumThumbnailAssetId) {
album.thumbnail.value = await _db.assets
.where()
.remoteIdEqualTo(dto.albumThumbnailAssetId)
.findFirst();
}
// write & commit all changes to DB
try {
await _db.writeTxn(() async {
await _db.assets.putAll(toUpdate);
await album.thumbnail.save();
await album.sharedUsers
.update(link: usersToLink, unlink: usersToUnlink);
await album.assets.update(link: assetsToLink, unlink: toUnlink.cast());
await _db.albums.put(album);
});
} on IsarError catch (e) {
debugPrint(e.toString());
}
if (album.shared || dto.shared) {
final userId = Store.get<User>(StoreKey.currentUser)!.isarId;
final foreign =
await album.assets.filter().not().ownerIdEqualTo(userId).findAll();
existing.addAll(foreign);
// delete assets in DB unless they belong to this user or part of some other shared album
deleteCandidates.addAll(toUnlink.where((a) => a.ownerId != userId));
}
return true;
}
/// Adds a remote album to the database while making sure to add any foreign
/// (shared) assets to the database beforehand
/// accumulates assets already existing in the database
Future<void> _addAlbumFromServer(
AlbumResponseDto dto,
List<Asset> existing,
FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
) async {
if (dto.assetCount != dto.assets.length) {
dto = await loadDetails(dto);
}
if (dto.assetCount == dto.assets.length) {
// in case an album contains assets not yet present in local DB:
// put missing album assets into local DB
final result = await _linkWithExistingFromDb(dto.getAssets());
existing.addAll(result.first);
await _upsertAssetsWithExif(result.second);
final Album a = await Album.remote(dto);
await _db.writeTxn(() => _db.albums.store(a));
}
}
/// Accumulates all suitable album assets to the `deleteCandidates` and
/// removes the album from the database.
Future<void> _removeAlbumFromDb(
Album album,
List<Asset> deleteCandidates,
) async {
if (album.isLocal) {
// delete assets in DB unless they are remote or part of some other album
deleteCandidates.addAll(
await album.assets.filter().remoteIdIsNull().findAll(),
);
} else if (album.shared) {
final User user = Store.get(StoreKey.currentUser);
// delete assets in DB unless they belong to this user or are part of some other shared album
deleteCandidates.addAll(
await album.assets.filter().not().ownerIdEqualTo(user.isarId).findAll(),
);
}
final bool ok = await _db.writeTxn(() => _db.albums.delete(album.id));
assert(ok);
}
/// Syncs all device albums and their assets to the database
/// Returns `true` if there were any changes
Future<bool> _syncLocalAlbumAssetsToDb(List<AssetPathEntity> onDevice) async {
onDevice.sort((a, b) => a.id.compareTo(b.id));
final List<Album> inDb =
await _db.albums.where().localIdIsNotNull().sortByLocalId().findAll();
final List<Asset> deleteCandidates = [];
final List<Asset> existing = [];
final bool anyChanges = await diffSortedLists(
onDevice,
inDb,
compare: (AssetPathEntity a, Album b) => a.id.compareTo(b.localId!),
both: (AssetPathEntity ape, Album album) =>
_syncAlbumInDbAndOnDevice(ape, album, deleteCandidates, existing),
onlyFirst: (AssetPathEntity ape) => _addAlbumFromDevice(ape, existing),
onlySecond: (Album a) => _removeAlbumFromDb(a, deleteCandidates),
);
final pair = _handleAssetRemoval(deleteCandidates, existing);
if (pair.first.isNotEmpty || pair.second.isNotEmpty) {
await _db.writeTxn(() async {
await _db.assets.deleteAll(pair.first);
await _db.assets.putAll(pair.second);
});
}
return anyChanges;
}
/// Syncs the device album to the album in the database
/// returns `true` if there were any changes
/// Accumulates asset candidates to delete and those already existing in DB
Future<bool> _syncAlbumInDbAndOnDevice(
AssetPathEntity ape,
Album album,
List<Asset> deleteCandidates,
List<Asset> existing, [
bool forceRefresh = false,
]) async {
if (!forceRefresh && !await _hasAssetPathEntityChanged(ape, album)) {
return false;
}
if (!forceRefresh && await _syncDeviceAlbumFast(ape, album)) {
return true;
}
// general case, e.g. some assets have been deleted
final inDb = await album.assets.filter().sortByLocalId().findAll();
final List<Asset> onDevice = await ape.getAssets();
onDevice.sort(Asset.compareByLocalId);
final d = _diffAssets(onDevice, inDb, compare: Asset.compareByLocalId);
final List<Asset> toAdd = d.first, toUpdate = d.second, toDelete = d.third;
final result = await _linkWithExistingFromDb(toAdd);
deleteCandidates.addAll(toDelete);
existing.addAll(result.first);
album.name = ape.name;
album.modifiedAt = ape.lastModified!;
if (album.thumbnail.value != null &&
toDelete.contains(album.thumbnail.value)) {
album.thumbnail.value = null;
}
try {
await _db.writeTxn(() async {
await _db.assets.putAll(result.second);
await _db.assets.putAll(toUpdate);
await album.assets
.update(link: result.first + result.second, unlink: toDelete);
await _db.albums.put(album);
album.thumbnail.value ??= await album.assets.filter().findFirst();
await album.thumbnail.save();
});
} on IsarError catch (e) {
debugPrint(e.toString());
}
return true;
}
/// fast path for common case: only new assets were added to device album
/// returns `true` if successfull, else `false`
Future<bool> _syncDeviceAlbumFast(AssetPathEntity ape, Album album) async {
final int totalOnDevice = await ape.assetCountAsync;
final AssetPathEntity? modified = totalOnDevice > album.assetCount
? await ape.fetchPathProperties(
filterOptionGroup: FilterOptionGroup(
updateTimeCond: DateTimeCond(
min: album.modifiedAt.add(const Duration(seconds: 1)),
max: ape.lastModified!,
),
),
)
: null;
if (modified == null) {
return false;
}
final List<Asset> newAssets = await modified.getAssets();
if (totalOnDevice != album.assets.length + newAssets.length) {
return false;
}
album.modifiedAt = ape.lastModified!.toUtc();
final result = await _linkWithExistingFromDb(newAssets);
try {
await _db.writeTxn(() async {
await _db.assets.putAll(result.second);
await album.assets.update(link: result.first + result.second);
await _db.albums.put(album);
});
} on IsarError catch (e) {
debugPrint(e.toString());
}
return true;
}
/// Adds a new album from the device to the database and Accumulates all
/// assets already existing in the database to the list of `existing` assets
Future<void> _addAlbumFromDevice(
AssetPathEntity ape,
List<Asset> existing,
) async {
final Album a = Album.local(ape);
final result = await _linkWithExistingFromDb(await ape.getAssets());
await _upsertAssetsWithExif(result.second);
existing.addAll(result.first);
a.assets.addAll(result.first);
a.assets.addAll(result.second);
final thumb = result.first.firstOrNull ?? result.second.firstOrNull;
a.thumbnail.value = thumb;
try {
await _db.writeTxn(() => _db.albums.store(a));
} on IsarError catch (e) {
debugPrint(e.toString());
}
}
/// Returns a tuple (existing, updated)
Future<Pair<List<Asset>, List<Asset>>> _linkWithExistingFromDb(
List<Asset> assets,
) async {
if (assets.isEmpty) {
return const Pair([], []);
}
final List<Asset> inDb = await _db.assets
.where()
.anyOf(
assets,
(q, Asset e) => q.localIdDeviceIdEqualTo(e.localId, e.deviceId),
)
.sortByDeviceId()
.thenByLocalId()
.findAll();
assets.sort(Asset.compareByDeviceIdLocalId);
final List<Asset> existing = [], toUpsert = [];
diffSortedListsSync(
inDb,
assets,
compare: Asset.compareByDeviceIdLocalId,
both: (Asset a, Asset b) {
if ((a.isLocal || !b.isLocal) &&
(a.isRemote || !b.isRemote) &&
a.updatedAt == b.updatedAt) {
existing.add(a);
return false;
} else {
toUpsert.add(b.updateFromDb(a));
return true;
}
},
onlyFirst: (Asset a) => throw Exception("programming error"),
onlySecond: (Asset b) => toUpsert.add(b),
);
return Pair(existing, toUpsert);
}
/// Inserts or updates the assets in the database with their ExifInfo (if any)
Future<void> _upsertAssetsWithExif(List<Asset> assets) async {
if (assets.isEmpty) {
return;
}
final exifInfos = assets.map((e) => e.exifInfo).whereNotNull().toList();
try {
await _db.writeTxn(() async {
await _db.assets.putAll(assets);
for (final Asset added in assets) {
added.exifInfo?.id = added.id;
}
await _db.exifInfos.putAll(exifInfos);
});
} on IsarError catch (e) {
debugPrint(e.toString());
}
}
}
/// Returns a triple(toAdd, toUpdate, toRemove)
Triple<List<Asset>, List<Asset>, List<Asset>> _diffAssets(
List<Asset> assets,
List<Asset> inDb, {
bool? remote,
int Function(Asset, Asset) compare = Asset.compareByDeviceIdLocalId,
}) {
final List<Asset> toAdd = [];
final List<Asset> toUpdate = [];
final List<Asset> toRemove = [];
diffSortedListsSync(
inDb,
assets,
compare: compare,
both: (Asset a, Asset b) {
if (a.updatedAt.isBefore(b.updatedAt) ||
(!a.isLocal && b.isLocal) ||
(!a.isRemote && b.isRemote)) {
toUpdate.add(b.updateFromDb(a));
debugPrint("both");
return true;
}
return false;
},
onlyFirst: (Asset a) {
if (remote == true && a.isLocal) {
if (a.remoteId != null) {
a.remoteId = null;
toUpdate.add(a);
}
} else if (remote == false && a.isRemote) {
if (a.isLocal) {
a.isLocal = false;
toUpdate.add(a);
}
} else {
toRemove.add(a);
}
},
onlySecond: (Asset b) => toAdd.add(b),
);
return Triple(toAdd, toUpdate, toRemove);
}
/// returns a tuple (toDelete toUpdate) when assets are to be deleted
Pair<List<int>, List<Asset>> _handleAssetRemoval(
List<Asset> deleteCandidates,
List<Asset> existing,
) {
if (deleteCandidates.isEmpty) {
return const Pair([], []);
}
deleteCandidates.sort(Asset.compareById);
existing.sort(Asset.compareById);
final triple =
_diffAssets(existing, deleteCandidates, compare: Asset.compareById);
return Pair(triple.third.map((e) => e.id).toList(), triple.second);
}
/// returns `true` if the albums differ on the surface
Future<bool> _hasAssetPathEntityChanged(AssetPathEntity a, Album b) async {
return a.name != b.name ||
a.lastModified != b.modifiedAt ||
await a.assetCountAsync != b.assetCount;
}
/// returns `true` if the albums differ on the surface
bool _hasAlbumResponseDtoChanged(AlbumResponseDto dto, Album a) {
return dto.assetCount != a.assetCount ||
dto.albumName != a.name ||
dto.albumThumbnailAssetId != a.thumbnail.value?.remoteId ||
dto.shared != a.shared ||
DateTime.parse(dto.updatedAt).toUtc() != a.modifiedAt.toUtc();
}

View File

@@ -3,24 +3,32 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart';
import 'package:http_parser/http_parser.dart';
import 'package:image_picker/image_picker.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/shared/services/sync.service.dart';
import 'package:immich_mobile/utils/files_helper.dart';
import 'package:isar/isar.dart';
import 'package:openapi/api.dart';
final userServiceProvider = Provider(
(ref) => UserService(
ref.watch(apiServiceProvider),
ref.watch(dbProvider),
ref.watch(syncServiceProvider),
),
);
class UserService {
final ApiService _apiService;
final Isar _db;
final SyncService _syncService;
UserService(this._apiService);
UserService(this._apiService, this._db, this._syncService);
Future<List<User>?> getAllUsers({required bool isAll}) async {
Future<List<User>?> _getAllUsers({required bool isAll}) async {
try {
final dto = await _apiService.userApi.getAllUsers(isAll);
return dto?.map(User.fromDto).toList();
@@ -30,6 +38,14 @@ class UserService {
}
}
Future<List<User>> getUsersInDb({bool self = false}) async {
if (self) {
return _db.users.where().findAll();
}
final int userId = Store.get<User>(StoreKey.currentUser)!.isarId;
return _db.users.where().isarIdNotEqualTo(userId).findAll();
}
Future<CreateProfileImageResponseDto?> uploadProfileImage(XFile image) async {
try {
var mimeType = FileHelper.getMimeType(image.path);
@@ -50,4 +66,12 @@ class UserService {
return null;
}
}
Future<bool> refreshUsers() async {
final List<User>? users = await _getAllUsers(isAll: true);
if (users == null) {
return false;
}
return _syncService.syncUsersFromServer(users);
}
}