Source Code added

This commit is contained in:
Fr4nz D13trich 2026-02-02 15:06:40 +01:00
parent 800376eafd
commit 9efa9bc6dd
3912 changed files with 754770 additions and 2 deletions

View file

@ -0,0 +1,50 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/infrastructure/utils/user.converter.dart';
import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/api.repository.dart';
import 'package:openapi/api.dart';
final activityApiRepositoryProvider = Provider(
(ref) => ActivityApiRepository(ref.watch(apiServiceProvider).activitiesApi),
);
class ActivityApiRepository extends ApiRepository {
final ActivitiesApi _api;
ActivityApiRepository(this._api);
Future<List<Activity>> getAll(String albumId, {String? assetId}) async {
final response = await checkNull(_api.getActivities(albumId, assetId: assetId));
return response.map(_toActivity).toList();
}
Future<Activity> create(String albumId, ActivityType type, {String? assetId, String? comment}) async {
final dto = ActivityCreateDto(
albumId: albumId,
type: type == ActivityType.comment ? ReactionType.comment : ReactionType.like,
assetId: assetId,
comment: comment,
);
final response = await checkNull(_api.createActivity(dto));
return _toActivity(response);
}
Future<void> delete(String id) {
return checkNull(_api.deleteActivity(id));
}
Future<ActivityStats> getStats(String albumId, {String? assetId}) async {
final response = await checkNull(_api.getActivityStatistics(albumId, assetId: assetId));
return ActivityStats(comments: response.comments);
}
static Activity _toActivity(ActivityResponseDto dto) => Activity(
id: dto.id,
createdAt: dto.createdAt,
type: dto.type == ReactionType.comment ? ActivityType.comment : ActivityType.like,
user: UserConverter.fromSimpleUserDto(dto.user),
assetId: dto.assetId,
comment: dto.comment,
);
}

View file

@ -0,0 +1,139 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart' as entity;
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/repositories/database.repository.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:isar/isar.dart';
enum AlbumSort { remoteId, localId }
final albumRepositoryProvider = Provider((ref) => AlbumRepository(ref.watch(dbProvider)));
class AlbumRepository extends DatabaseRepository {
const AlbumRepository(super.db);
Future<int> count({bool? local}) {
final baseQuery = db.albums.where();
final QueryBuilder<Album, Album, QAfterWhereClause> query = switch (local) {
null => baseQuery.noOp(),
true => baseQuery.localIdIsNotNull(),
false => baseQuery.remoteIdIsNotNull(),
};
return query.count();
}
Future<Album> create(Album album) => txn(() => db.albums.store(album));
Future<Album?> getByName(String name, {bool? shared, bool? remote, bool? owner}) {
var query = db.albums.filter().nameEqualTo(name);
if (shared != null) {
query = query.sharedEqualTo(shared);
}
final isarUserId = fastHash(Store.get(StoreKey.currentUser).id);
if (owner == true) {
query = query.owner((q) => q.isarIdEqualTo(isarUserId));
} else if (owner == false) {
query = query.owner((q) => q.not().isarIdEqualTo(isarUserId));
}
if (remote == true) {
query = query.localIdIsNull();
} else if (remote == false) {
query = query.remoteIdIsNull();
}
return query.findFirst();
}
Future<Album> update(Album album) => txn(() => db.albums.store(album));
Future<void> delete(int albumId) => txn(() => db.albums.delete(albumId));
Future<List<Album>> getAll({bool? shared, bool? remote, int? ownerId, AlbumSort? sortBy}) {
final baseQuery = db.albums.where();
final QueryBuilder<Album, Album, QAfterWhereClause> afterWhere;
if (remote == null) {
afterWhere = baseQuery.noOp();
} else if (remote) {
afterWhere = baseQuery.remoteIdIsNotNull();
} else {
afterWhere = baseQuery.localIdIsNotNull();
}
QueryBuilder<Album, Album, QAfterFilterCondition> filterQuery = afterWhere.filter().noOp();
if (shared != null) {
filterQuery = filterQuery.sharedEqualTo(true);
}
if (ownerId != null) {
filterQuery = filterQuery.owner((q) => q.isarIdEqualTo(ownerId));
}
final QueryBuilder<Album, Album, QAfterSortBy> query = switch (sortBy) {
null => filterQuery.noOp(),
AlbumSort.remoteId => filterQuery.sortByRemoteId(),
AlbumSort.localId => filterQuery.sortByLocalId(),
};
return query.findAll();
}
Future<Album?> get(int id) => db.albums.get(id);
Future<Album?> getByRemoteId(String remoteId) {
return db.albums.filter().remoteIdEqualTo(remoteId).findFirst();
}
Future<void> removeUsers(Album album, List<UserDto> users) =>
txn(() => album.sharedUsers.update(unlink: users.map(entity.User.fromDto)));
Future<void> addAssets(Album album, List<Asset> assets) => txn(() => album.assets.update(link: assets));
Future<void> removeAssets(Album album, List<Asset> assets) => txn(() => album.assets.update(unlink: assets));
Future<Album> recalculateMetadata(Album album) async {
album.startDate = await album.assets.filter().fileCreatedAtProperty().min();
album.endDate = await album.assets.filter().fileCreatedAtProperty().max();
album.lastModifiedAssetTimestamp = await album.assets.filter().updatedAtProperty().max();
return album;
}
Future<void> addUsers(Album album, List<UserDto> users) =>
txn(() => album.sharedUsers.update(link: users.map(entity.User.fromDto)));
Future<void> deleteAllLocal() => txn(() => db.albums.where().localIdIsNotNull().deleteAll());
Future<List<Album>> search(String searchTerm, QuickFilterMode filterMode) async {
var query = db.albums.filter().nameContains(searchTerm, caseSensitive: false).remoteIdIsNotNull();
final isarUserId = fastHash(Store.get(StoreKey.currentUser).id);
switch (filterMode) {
case QuickFilterMode.sharedWithMe:
query = query.owner((q) => q.not().isarIdEqualTo(isarUserId));
case QuickFilterMode.myAlbums:
query = query.owner((q) => q.isarIdEqualTo(isarUserId));
case QuickFilterMode.all:
break;
}
return await query.findAll();
}
Future<void> clearTable() async {
await txn(() async {
await db.albums.clear();
});
}
Stream<List<Album>> watchRemoteAlbums() {
return db.albums.where().remoteIdIsNotNull().watch();
}
Stream<List<Album>> watchLocalAlbums() {
return db.albums.where().localIdIsNotNull().watch();
}
Stream<Album?> watchAlbum(int id) {
return db.albums.watchObject(id, fireImmediately: true);
}
}

View file

@ -0,0 +1,171 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart' show AlbumAssetOrder, RemoteAlbum;
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart' as entity;
import 'package:immich_mobile/infrastructure/utils/user.converter.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/api.repository.dart';
import 'package:openapi/api.dart';
final albumApiRepositoryProvider = Provider((ref) => AlbumApiRepository(ref.watch(apiServiceProvider).albumsApi));
class AlbumApiRepository extends ApiRepository {
final AlbumsApi _api;
AlbumApiRepository(this._api);
Future<Album> get(String id) async {
final dto = await checkNull(_api.getAlbumInfo(id));
return _toAlbum(dto);
}
Future<List<Album>> getAll({bool? shared}) async {
final dtos = await checkNull(_api.getAllAlbums(shared: shared));
return dtos.map(_toAlbum).toList();
}
Future<Album> create(
String name, {
required Iterable<String> assetIds,
Iterable<String> sharedUserIds = const [],
String? description,
}) async {
final users = sharedUserIds.map((id) => AlbumUserCreateDto(userId: id, role: AlbumUserRole.editor));
final responseDto = await checkNull(
_api.createAlbum(
CreateAlbumDto(
albumName: name,
description: description,
assetIds: assetIds.toList(),
albumUsers: users.toList(),
),
),
);
return _toAlbum(responseDto);
}
// TODO: Change name after removing old method
Future<RemoteAlbum> createDriftAlbum(String name, {required Iterable<String> assetIds, String? description}) async {
final responseDto = await checkNull(
_api.createAlbum(CreateAlbumDto(albumName: name, description: description, assetIds: assetIds.toList())),
);
return _toRemoteAlbum(responseDto);
}
Future<Album> update(
String albumId, {
String? name,
String? thumbnailAssetId,
String? description,
bool? activityEnabled,
SortOrder? sortOrder,
}) async {
AssetOrder? order;
if (sortOrder != null) {
order = sortOrder == SortOrder.asc ? AssetOrder.asc : AssetOrder.desc;
}
final response = await checkNull(
_api.updateAlbumInfo(
albumId,
UpdateAlbumDto(
albumName: name,
albumThumbnailAssetId: thumbnailAssetId,
description: description,
isActivityEnabled: activityEnabled,
order: order,
),
),
);
return _toAlbum(response);
}
Future<void> delete(String albumId) {
return _api.deleteAlbum(albumId);
}
Future<({List<String> added, List<String> duplicates})> addAssets(String albumId, Iterable<String> assetIds) async {
final response = await checkNull(_api.addAssetsToAlbum(albumId, BulkIdsDto(ids: assetIds.toList())));
final List<String> added = [];
final List<String> duplicates = [];
for (final result in response) {
if (result.success) {
added.add(result.id);
} else if (result.error == BulkIdResponseDtoErrorEnum.duplicate) {
duplicates.add(result.id);
}
}
return (added: added, duplicates: duplicates);
}
Future<({List<String> removed, List<String> failed})> removeAssets(String albumId, Iterable<String> assetIds) async {
final response = await checkNull(_api.removeAssetFromAlbum(albumId, BulkIdsDto(ids: assetIds.toList())));
final List<String> removed = [], failed = [];
for (final dto in response) {
if (dto.success) {
removed.add(dto.id);
} else {
failed.add(dto.id);
}
}
return (removed: removed, failed: failed);
}
Future<Album> addUsers(String albumId, Iterable<String> userIds) async {
final albumUsers = userIds.map((userId) => AlbumUserAddDto(userId: userId)).toList();
final response = await checkNull(_api.addUsersToAlbum(albumId, AddUsersDto(albumUsers: albumUsers)));
return _toAlbum(response);
}
Future<void> removeUser(String albumId, {required String userId}) {
return _api.removeUserFromAlbum(albumId, userId);
}
static Album _toAlbum(AlbumResponseDto dto) {
final Album album = Album(
remoteId: dto.id,
name: dto.albumName,
createdAt: dto.createdAt,
modifiedAt: dto.updatedAt,
lastModifiedAssetTimestamp: dto.lastModifiedAssetTimestamp,
shared: dto.shared,
startDate: dto.startDate,
description: dto.description,
endDate: dto.endDate,
activityEnabled: dto.isActivityEnabled,
sortOrder: dto.order == AssetOrder.asc ? SortOrder.asc : SortOrder.desc,
);
album.remoteAssetCount = dto.assetCount;
album.owner.value = entity.User.fromDto(UserConverter.fromSimpleUserDto(dto.owner));
album.remoteThumbnailAssetId = dto.albumThumbnailAssetId;
final users = dto.albumUsers.map((albumUser) => UserConverter.fromSimpleUserDto(albumUser.user));
album.sharedUsers.addAll(users.map(entity.User.fromDto));
final assets = dto.assets.map(Asset.remote).toList();
album.assets.addAll(assets);
return album;
}
static RemoteAlbum _toRemoteAlbum(AlbumResponseDto dto) {
return RemoteAlbum(
id: dto.id,
name: dto.albumName,
ownerId: dto.owner.id,
description: dto.description,
createdAt: dto.createdAt,
updatedAt: dto.updatedAt,
thumbnailAssetId: dto.albumThumbnailAssetId,
isActivityEnabled: dto.isActivityEnabled,
order: dto.order == AssetOrder.asc ? AlbumAssetOrder.asc : AlbumAssetOrder.desc,
assetCount: dto.assetCount,
ownerName: dto.owner.name,
isShared: dto.albumUsers.length > 2,
);
}
}

View file

@ -0,0 +1,99 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:photo_manager/photo_manager.dart' hide AssetType;
final albumMediaRepositoryProvider = Provider((ref) => const AlbumMediaRepository());
class AlbumMediaRepository {
const AlbumMediaRepository();
bool get useCustomFilter => Store.get(StoreKey.photoManagerCustomFilter, true);
FilterOptionGroup? _getAlbumFilter({
DateTimeCond? updateTimeCond,
bool? containsPathModified,
List<OrderOption>? orderBy,
}) => useCustomFilter
? FilterOptionGroup(
imageOption: const FilterOption(needTitle: true, sizeConstraint: SizeConstraint(ignoreSize: true)),
videoOption: const FilterOption(
needTitle: true,
sizeConstraint: SizeConstraint(ignoreSize: true),
durationConstraint: DurationConstraint(allowNullable: true),
),
containsPathModified: containsPathModified ?? false,
createTimeCond: DateTimeCond.def().copyWith(ignore: true),
updateTimeCond: updateTimeCond ?? DateTimeCond.def().copyWith(ignore: true),
orders: orderBy ?? [],
)
: null;
Future<List<Album>> getAll() async {
final filter = useCustomFilter
? CustomFilter.sql(where: '${CustomColumns.base.width} > 0')
: FilterOptionGroup(containsPathModified: true);
final List<AssetPathEntity> assetPathEntities = await PhotoManager.getAssetPathList(
hasAll: true,
filterOption: filter,
);
return assetPathEntities.map(_toAlbum).toList();
}
Future<List<String>> getAssetIds(String albumId) async {
final album = await AssetPathEntity.fromId(albumId, filterOption: _getAlbumFilter());
final List<AssetEntity> assets = await album.getAssetListRange(start: 0, end: 0x7fffffffffffffff);
return assets.map((e) => e.id).toList();
}
Future<int> getAssetCount(String albumId) async {
final album = await AssetPathEntity.fromId(albumId, filterOption: _getAlbumFilter());
return album.assetCountAsync;
}
Future<List<Asset>> getAssets(
String albumId, {
int start = 0,
int end = 0x7fffffffffffffff,
DateTime? modifiedFrom,
DateTime? modifiedUntil,
bool orderByModificationDate = false,
}) async {
final onDevice = await AssetPathEntity.fromId(
albumId,
filterOption: _getAlbumFilter(
updateTimeCond: modifiedFrom == null && modifiedUntil == null
? null
: DateTimeCond(min: modifiedFrom ?? DateTime.utc(-271820), max: modifiedUntil ?? DateTime.utc(275760)),
orderBy: orderByModificationDate ? [const OrderOption(type: OrderOptionType.updateDate)] : [],
),
);
final List<AssetEntity> assets = await onDevice.getAssetListRange(start: start, end: end);
return assets.map(AssetMediaRepository.toAsset).toList().cast();
}
Future<Album> get(String id) async {
final assetPathEntity = await AssetPathEntity.fromId(id, filterOption: _getAlbumFilter(containsPathModified: true));
return _toAlbum(assetPathEntity);
}
static Album _toAlbum(AssetPathEntity assetPathEntity) {
final Album album = Album(
name: assetPathEntity.name,
createdAt: assetPathEntity.lastModified?.toUtc() ?? DateTime.now().toUtc(),
modifiedAt: assetPathEntity.lastModified?.toUtc() ?? DateTime.now().toUtc(),
shared: false,
activityEnabled: false,
);
album.owner.value = User.fromDto(Store.get(StoreKey.currentUser));
album.localId = assetPathEntity.id;
album.isAll = assetPathEntity.isAll;
return album;
}
}

View file

@ -0,0 +1,9 @@
import 'package:immich_mobile/constants/errors.dart';
abstract class ApiRepository {
Future<T> checkNull<T>(Future<T?> future) async {
final response = await future;
if (response == null) throw const NoResponseDtoError();
return response;
}
}

View file

@ -0,0 +1,220 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/repositories/database.repository.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:isar/isar.dart';
enum AssetSort { checksum, ownerIdChecksum }
final assetRepositoryProvider = Provider((ref) => AssetRepository(ref.watch(dbProvider)));
class AssetRepository extends DatabaseRepository {
const AssetRepository(super.db);
Future<List<Asset>> getByAlbum(
Album album, {
Iterable<String> notOwnedBy = const [],
String? ownerId,
AssetState? state,
AssetSort? sortBy,
}) {
var query = album.assets.filter();
final isarUserIds = notOwnedBy.map(fastHash).toList();
if (notOwnedBy.length == 1) {
query = query.not().ownerIdEqualTo(isarUserIds.first);
} else if (notOwnedBy.isNotEmpty) {
query = query.not().anyOf(isarUserIds, (q, int id) => q.ownerIdEqualTo(id));
}
if (ownerId != null) {
query = query.ownerIdEqualTo(fastHash(ownerId));
}
if (state != null) {
query = switch (state) {
AssetState.local => query.remoteIdIsNull(),
AssetState.remote => query.localIdIsNull(),
AssetState.merged => query.localIdIsNotNull().remoteIdIsNotNull(),
};
}
final QueryBuilder<Asset, Asset, QAfterSortBy> sortedQuery = switch (sortBy) {
null => query.noOp(),
AssetSort.checksum => query.sortByChecksum(),
AssetSort.ownerIdChecksum => query.sortByOwnerId().thenByChecksum(),
};
return sortedQuery.findAll();
}
Future<void> deleteByIds(List<int> ids) => txn(() async {
await db.assets.deleteAll(ids);
await db.exifInfos.deleteAll(ids);
});
Future<Asset?> getByRemoteId(String id) => db.assets.getByRemoteId(id);
Future<List<Asset>> getAllByRemoteId(Iterable<String> ids, {AssetState? state}) async {
if (ids.isEmpty) {
return [];
}
return _getAllByRemoteIdImpl(ids, state).findAll();
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> _getAllByRemoteIdImpl(Iterable<String> ids, AssetState? state) {
final query = db.assets.remote(ids).filter();
return switch (state) {
null => query.noOp(),
AssetState.local => query.remoteIdIsNull(),
AssetState.remote => query.localIdIsNull(),
AssetState.merged => query.localIdIsNotEmpty().remoteIdIsNotNull(),
};
}
Future<List<Asset>> getAll({required String ownerId, AssetState? state, AssetSort? sortBy, int? limit}) {
final baseQuery = db.assets.where();
final isarUserIds = fastHash(ownerId);
final QueryBuilder<Asset, Asset, QAfterFilterCondition> filteredQuery = switch (state) {
null => baseQuery.ownerIdEqualToAnyChecksum(isarUserIds).noOp(),
AssetState.local => baseQuery.remoteIdIsNull().filter().localIdIsNotNull().ownerIdEqualTo(isarUserIds),
AssetState.remote => baseQuery.localIdIsNull().filter().remoteIdIsNotNull().ownerIdEqualTo(isarUserIds),
AssetState.merged =>
baseQuery.ownerIdEqualToAnyChecksum(isarUserIds).filter().remoteIdIsNotNull().localIdIsNotNull(),
};
final QueryBuilder<Asset, Asset, QAfterSortBy> query = switch (sortBy) {
null => filteredQuery.noOp(),
AssetSort.checksum => filteredQuery.sortByChecksum(),
AssetSort.ownerIdChecksum => filteredQuery.sortByOwnerId().thenByChecksum(),
};
return limit == null ? query.findAll() : query.limit(limit).findAll();
}
Future<List<Asset>> updateAll(List<Asset> assets) async {
await txn(() => db.assets.putAll(assets));
return assets;
}
Future<List<Asset>> getMatches({
required List<Asset> assets,
required String ownerId,
AssetState? state,
int limit = 100,
}) {
final baseQuery = db.assets.where();
final QueryBuilder<Asset, Asset, QAfterFilterCondition> query = switch (state) {
null => baseQuery.noOp(),
AssetState.local => baseQuery.remoteIdIsNull().filter().localIdIsNotNull(),
AssetState.remote => baseQuery.localIdIsNull().filter().remoteIdIsNotNull(),
AssetState.merged => baseQuery.localIdIsNotNull().filter().remoteIdIsNotNull(),
};
return _getMatchesImpl(query, fastHash(ownerId), assets, limit);
}
Future<Asset> update(Asset asset) async {
await txn(() => asset.put(db));
return asset;
}
Future<void> upsertDuplicatedAssets(Iterable<String> duplicatedAssets) =>
txn(() => db.duplicatedAssets.putAll(duplicatedAssets.map(DuplicatedAsset.new).toList()));
Future<List<String>> getAllDuplicatedAssetIds() => db.duplicatedAssets.where().idProperty().findAll();
Future<Asset?> getByOwnerIdChecksum(int ownerId, String checksum) =>
db.assets.getByOwnerIdChecksum(ownerId, checksum);
Future<List<Asset?>> getAllByOwnerIdChecksum(List<int> ownerIds, List<String> checksums) =>
db.assets.getAllByOwnerIdChecksum(ownerIds, checksums);
Future<List<Asset>> getAllLocal() => db.assets.where().localIdIsNotNull().findAll();
Future<void> deleteAllByRemoteId(List<String> ids, {AssetState? state}) =>
txn(() => _getAllByRemoteIdImpl(ids, state).deleteAll());
Future<List<Asset>> getStackAssets(String stackId) {
return db.assets
.filter()
.isArchivedEqualTo(false)
.isTrashedEqualTo(false)
.stackIdEqualTo(stackId)
// orders primary asset first as its ID is null
.sortByStackPrimaryAssetId()
.thenByFileCreatedAtDesc()
.findAll();
}
Future<void> clearTable() async {
await txn(() async {
await db.assets.clear();
});
}
Stream<Asset?> watchAsset(int id, {bool fireImmediately = false}) {
return db.assets.watchObject(id, fireImmediately: fireImmediately);
}
Future<List<Asset>> getTrashAssets(String userId) {
return db.assets
.where()
.remoteIdIsNotNull()
.filter()
.ownerIdEqualTo(fastHash(userId))
.isTrashedEqualTo(true)
.findAll();
}
Future<List<Asset>> getRecentlyTakenAssets(String userId) {
return db.assets
.where()
.ownerIdEqualToAnyChecksum(fastHash(userId))
.filter()
.visibilityEqualTo(AssetVisibilityEnum.timeline)
.sortByFileCreatedAtDesc()
.findAll();
}
Future<List<Asset>> getMotionAssets(String userId) {
return db.assets
.where()
.ownerIdEqualToAnyChecksum(fastHash(userId))
.filter()
.visibilityEqualTo(AssetVisibilityEnum.timeline)
.livePhotoVideoIdIsNotNull()
.findAll();
}
}
Future<List<Asset>> _getMatchesImpl(
QueryBuilder<Asset, Asset, QAfterFilterCondition> query,
int ownerId,
List<Asset> assets,
int limit,
) => query
.ownerIdEqualTo(ownerId)
.anyOf(
assets,
(q, Asset a) => q
.fileNameEqualTo(a.fileName)
.and()
.durationInSecondsEqualTo(a.durationInSeconds)
.and()
.fileCreatedAtBetween(
a.fileCreatedAt.subtract(const Duration(hours: 12)),
a.fileCreatedAt.add(const Duration(hours: 12)),
)
.and()
.not()
.checksumEqualTo(a.checksum),
)
.sortByFileName()
.thenByFileCreatedAt()
.thenByFileModifiedAt()
.limit(limit)
.findAll();

View file

@ -0,0 +1,114 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/stack.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/api.repository.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:openapi/api.dart';
final assetApiRepositoryProvider = Provider(
(ref) => AssetApiRepository(
ref.watch(apiServiceProvider).assetsApi,
ref.watch(apiServiceProvider).searchApi,
ref.watch(apiServiceProvider).stacksApi,
ref.watch(apiServiceProvider).trashApi,
),
);
class AssetApiRepository extends ApiRepository {
final AssetsApi _api;
final SearchApi _searchApi;
final StacksApi _stacksApi;
final TrashApi _trashApi;
AssetApiRepository(this._api, this._searchApi, this._stacksApi, this._trashApi);
Future<Asset> update(String id, {String? description}) async {
final response = await checkNull(_api.updateAsset(id, UpdateAssetDto(description: description)));
return Asset.remote(response);
}
Future<List<Asset>> search({List<String> personIds = const []}) async {
// TODO this always fetches all assets, change API and usage to actually do pagination
final List<Asset> result = [];
bool hasNext = true;
int currentPage = 1;
while (hasNext) {
final response = await checkNull(
_searchApi.searchAssets(MetadataSearchDto(personIds: personIds, page: currentPage, size: 1000)),
);
result.addAll(response.assets.items.map(Asset.remote));
hasNext = response.assets.nextPage != null;
currentPage++;
}
return result;
}
Future<void> delete(List<String> ids, bool force) async {
return _api.deleteAssets(AssetBulkDeleteDto(ids: ids, force: force));
}
Future<void> restoreTrash(List<String> ids) async {
await _trashApi.restoreAssets(BulkIdsDto(ids: ids));
}
Future<void> updateVisibility(List<String> ids, AssetVisibilityEnum visibility) async {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, visibility: _mapVisibility(visibility)));
}
Future<void> updateFavorite(List<String> ids, bool isFavorite) async {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, isFavorite: isFavorite));
}
Future<void> updateLocation(List<String> ids, LatLng location) async {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, latitude: location.latitude, longitude: location.longitude));
}
Future<void> updateDateTime(List<String> ids, DateTime dateTime) async {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, dateTimeOriginal: dateTime.toIso8601String()));
}
Future<StackResponse> stack(List<String> ids) async {
final responseDto = await checkNull(_stacksApi.createStack(StackCreateDto(assetIds: ids)));
return responseDto.toStack();
}
Future<void> unStack(List<String> ids) async {
return _stacksApi.deleteStacks(BulkIdsDto(ids: ids));
}
Future<Response> downloadAsset(String id, {required bool edited}) {
return _api.downloadAssetWithHttpInfo(id, edited: edited);
}
_mapVisibility(AssetVisibilityEnum visibility) => switch (visibility) {
AssetVisibilityEnum.timeline => AssetVisibility.timeline,
AssetVisibilityEnum.hidden => AssetVisibility.hidden,
AssetVisibilityEnum.locked => AssetVisibility.locked,
AssetVisibilityEnum.archive => AssetVisibility.archive,
};
Future<String?> getAssetMIMEType(String assetId) async {
final response = await checkNull(_api.getAssetInfo(assetId));
// we need to get the MIME of the thumbnail once that gets added to the API
return response.originalMimeType;
}
Future<void> updateDescription(String assetId, String description) {
return _api.updateAsset(assetId, UpdateAssetDto(description: description));
}
Future<void> updateRating(String assetId, int rating) {
return _api.updateAsset(assetId, UpdateAssetDto(rating: rating));
}
}
extension on StackResponseDto {
StackResponse toStack() {
return StackResponse(id: id, primaryAssetId: primaryAssetId, assetIds: assets.map((asset) => asset.id).toList());
}
}

View file

@ -0,0 +1,169 @@
import 'dart:async';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart' as asset_entity;
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/response_extensions.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:share_plus/share_plus.dart';
final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider)));
class AssetMediaRepository {
final AssetApiRepository _assetApiRepository;
static final Logger _log = Logger("AssetMediaRepository");
const AssetMediaRepository(this._assetApiRepository);
Future<bool> _androidSupportsTrash() async {
if (Platform.isAndroid) {
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
int sdkVersion = androidInfo.version.sdkInt;
return sdkVersion >= 31;
}
return false;
}
Future<List<String>> deleteAll(List<String> ids) async {
if (CurrentPlatform.isAndroid) {
if (await _androidSupportsTrash()) {
return PhotoManager.editor.android.moveToTrash(
ids.map((e) => AssetEntity(id: e, width: 1, height: 1, typeInt: 0)).toList(),
);
} else {
return PhotoManager.editor.deleteWithIds(ids);
}
}
return PhotoManager.editor.deleteWithIds(ids);
}
Future<asset_entity.Asset?> get(String id) async {
final entity = await AssetEntity.fromId(id);
return toAsset(entity);
}
static asset_entity.Asset? toAsset(AssetEntity? local) {
if (local == null) return null;
final asset_entity.Asset asset = asset_entity.Asset(
checksum: "",
localId: local.id,
ownerId: fastHash(Store.get(StoreKey.currentUser).id),
fileCreatedAt: local.createDateTime,
fileModifiedAt: local.modifiedDateTime,
updatedAt: local.modifiedDateTime,
durationInSeconds: local.duration,
type: asset_entity.AssetType.values[local.typeInt],
fileName: local.title!,
width: local.width,
height: local.height,
isFavorite: local.isFavorite,
);
if (asset.fileCreatedAt.year == 1970) {
asset.fileCreatedAt = asset.fileModifiedAt;
}
if (local.latitude != null) {
asset.exifInfo = ExifInfo(latitude: local.latitude, longitude: local.longitude);
}
asset.local = local;
return asset;
}
Future<String?> getOriginalFilename(String id) async {
final entity = await AssetEntity.fromId(id);
if (entity == null) {
return null;
}
try {
// titleAsync gets the correct original filename for some assets on iOS
// otherwise using the `entity.title` would return a random GUID
final originalFilename = await entity.titleAsync;
// treat empty filename as missing
return originalFilename.isNotEmpty ? originalFilename : null;
} catch (e) {
_log.warning("Failed to get original filename for asset: $id. Error: $e");
return null;
}
}
// TODO: make this more efficient
Future<int> shareAssets(List<BaseAsset> assets, BuildContext context) async {
final downloadedXFiles = <XFile>[];
final tempFiles = <File>[];
for (var asset in assets) {
final localId = (asset is LocalAsset)
? asset.id
: asset is RemoteAsset
? asset.localId
: null;
if (localId != null && !asset.isEdited) {
File? f = await AssetEntity(id: localId, width: 1, height: 1, typeInt: 0).originFile;
downloadedXFiles.add(XFile(f!.path));
if (CurrentPlatform.isIOS) {
tempFiles.add(f);
}
} else {
final remoteId = (asset is RemoteAsset) ? asset.id : asset.remoteId;
if (remoteId == null) {
_log.warning("Asset has no remote ID for sharing: $asset");
continue;
}
final tempDir = await getTemporaryDirectory();
final name = asset.name;
final tempFile = await File('${tempDir.path}/$name').create();
final res = await _assetApiRepository.downloadAsset(remoteId, edited: true);
if (res.statusCode != 200) {
_log.severe("Download for $name failed", res.toLoggerString());
continue;
}
await tempFile.writeAsBytes(res.bodyBytes);
downloadedXFiles.add(XFile(tempFile.path));
tempFiles.add(tempFile);
}
}
if (downloadedXFiles.isEmpty) {
_log.warning("No asset can be retrieved for share");
return 0;
}
// we dont want to await the share result since the
// "preparing" dialog will not disappear until
final size = context.sizeData;
unawaited(
Share.shareXFiles(
downloadedXFiles,
sharePositionOrigin: Rect.fromPoints(Offset.zero, Offset(size.width / 3, size.height)),
).then((result) async {
for (var file in tempFiles) {
try {
await file.delete();
} catch (e) {
_log.warning("Failed to delete temporary file: ${file.path}", e);
}
}
}),
);
return downloadedXFiles.length;
}
}

View file

@ -0,0 +1,69 @@
import 'dart:convert';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/etag.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/repositories/database.repository.dart';
final authRepositoryProvider = Provider<AuthRepository>(
(ref) => AuthRepository(ref.watch(dbProvider), ref.watch(driftProvider)),
);
class AuthRepository extends DatabaseRepository {
final Drift _drift;
const AuthRepository(super.db, this._drift);
Future<void> clearLocalData() async {
await SyncStreamRepository(_drift).reset();
return db.writeTxn(() {
return Future.wait([
db.assets.clear(),
db.exifInfos.clear(),
db.albums.clear(),
db.eTags.clear(),
db.users.clear(),
]);
});
}
String getAccessToken() {
return Store.get(StoreKey.accessToken);
}
bool getEndpointSwitchingFeature() {
return Store.tryGet(StoreKey.autoEndpointSwitching) ?? false;
}
String? getPreferredWifiName() {
return Store.tryGet(StoreKey.preferredWifiName);
}
String? getLocalEndpoint() {
return Store.tryGet(StoreKey.localEndpoint);
}
List<AuxilaryEndpoint> getExternalEndpointList() {
final jsonString = Store.tryGet(StoreKey.externalEndpointList);
if (jsonString == null) {
return [];
}
final List<dynamic> jsonList = jsonDecode(jsonString);
final endpointList = jsonList.map((e) => AuxilaryEndpoint.fromJson(e)).toList();
return endpointList;
}
}

View file

@ -0,0 +1,61 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/auth/login_response.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/api.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:openapi/api.dart';
final authApiRepositoryProvider = Provider((ref) => AuthApiRepository(ref.watch(apiServiceProvider)));
class AuthApiRepository extends ApiRepository {
final ApiService _apiService;
AuthApiRepository(this._apiService);
Future<void> changePassword(String newPassword) async {
await _apiService.usersApi.updateMyUser(UserUpdateMeDto(password: newPassword));
}
Future<LoginResponse> login(String email, String password) async {
final loginResponseDto = await checkNull(
_apiService.authenticationApi.login(LoginCredentialDto(email: email, password: password)),
);
return _mapLoginReponse(loginResponseDto);
}
Future<void> logout() async {
if (_apiService.apiClient.basePath.isEmpty) return;
await _apiService.authenticationApi.logout().timeout(const Duration(seconds: 7));
}
_mapLoginReponse(LoginResponseDto dto) {
return LoginResponse(
accessToken: dto.accessToken,
isAdmin: dto.isAdmin,
name: dto.name,
profileImagePath: dto.profileImagePath,
shouldChangePassword: dto.shouldChangePassword,
userEmail: dto.userEmail,
userId: dto.userId,
);
}
Future<bool> unlockPinCode(String pinCode) async {
try {
await _apiService.authenticationApi.unlockAuthSession(SessionUnlockDto(pinCode: pinCode));
return true;
} catch (_) {
return false;
}
}
Future<void> setupPinCode(String pinCode) {
return _apiService.authenticationApi.setupPinCode(PinCodeSetupDto(pinCode: pinCode));
}
Future<void> lockPinCode() {
return _apiService.authenticationApi.lockAuthSession();
}
}

View file

@ -0,0 +1,32 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/repositories/database.repository.dart';
import 'package:isar/isar.dart';
enum BackupAlbumSort { id }
final backupAlbumRepositoryProvider = Provider((ref) => BackupAlbumRepository(ref.watch(dbProvider)));
class BackupAlbumRepository extends DatabaseRepository {
const BackupAlbumRepository(super.db);
Future<List<BackupAlbum>> getAll({BackupAlbumSort? sort}) {
final baseQuery = db.backupAlbums.where();
final QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> query = switch (sort) {
null => baseQuery.noOp(),
BackupAlbumSort.id => baseQuery.sortById(),
};
return query.findAll();
}
Future<List<String>> getIdsBySelection(BackupSelection backup) =>
db.backupAlbums.filter().selectionEqualTo(backup).idProperty().findAll();
Future<List<BackupAlbum>> getAllBySelection(BackupSelection backup) =>
db.backupAlbums.filter().selectionEqualTo(backup).findAll();
Future<void> deleteAll(List<int> ids) => txn(() => db.backupAlbums.deleteAll(ids));
Future<void> updateAll(List<BackupAlbum> backupAlbums) => txn(() => db.backupAlbums.putAll(backupAlbums));
}

View file

@ -0,0 +1,24 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/auth/biometric_status.model.dart';
import 'package:local_auth/local_auth.dart';
final biometricRepositoryProvider = Provider((ref) => BiometricRepository(LocalAuthentication()));
class BiometricRepository {
final LocalAuthentication _localAuth;
const BiometricRepository(this._localAuth);
Future<BiometricStatus> getStatus() async {
final bool canAuthenticateWithBiometrics = await _localAuth.canCheckBiometrics;
final bool canAuthenticate = canAuthenticateWithBiometrics || await _localAuth.isDeviceSupported();
final availableBiometric = await _localAuth.getAvailableBiometrics();
return BiometricStatus(canAuthenticate: canAuthenticate, availableBiometrics: availableBiometric);
}
Future<bool> authenticate(String? message) async {
return _localAuth.authenticate(localizedReason: message ?? 'please_auth_to_access'.tr());
}
}

View file

@ -0,0 +1,25 @@
import 'dart:async';
import 'package:immich_mobile/interfaces/database.interface.dart';
import 'package:isar/isar.dart';
/// copied from Isar; needed to check if an async transaction is already active
const Symbol _zoneTxn = #zoneTxn;
abstract class DatabaseRepository implements IDatabaseRepository {
final Isar db;
const DatabaseRepository(this.db);
bool get inTxn => Zone.current[_zoneTxn] != null;
Future<T> txn<T>(Future<T> Function() callback) => inTxn ? callback() : transaction(callback);
@override
Future<T> transaction<T>(Future<T> Function() callback) => db.writeTxn(callback);
}
extension Asd<T> on QueryBuilder<T, dynamic, dynamic> {
QueryBuilder<T, T, O> noOp<O>() {
// ignore: invalid_use_of_protected_member
return QueryBuilder.apply(this, (query) => query);
}
}

View file

@ -0,0 +1,137 @@
import 'dart:convert';
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
final downloadRepositoryProvider = Provider((ref) => DownloadRepository());
class DownloadRepository {
static final _downloader = FileDownloader();
static final _dummyTask = DownloadTask(
taskId: 'dummy',
url: '',
filename: 'dummy',
group: '',
updates: Updates.statusAndProgress,
);
static final _dummyMetadata = {'part': LivePhotosPart.image.index, 'id': ''};
void Function(TaskStatusUpdate)? onImageDownloadStatus;
void Function(TaskStatusUpdate)? onVideoDownloadStatus;
void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus;
void Function(TaskProgressUpdate)? onTaskProgress;
DownloadRepository() {
_downloader.registerCallbacks(
group: kDownloadGroupImage,
taskStatusCallback: (update) => onImageDownloadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
_downloader.registerCallbacks(
group: kDownloadGroupVideo,
taskStatusCallback: (update) => onVideoDownloadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
_downloader.registerCallbacks(
group: kDownloadGroupLivePhoto,
taskStatusCallback: (update) => onLivePhotoDownloadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
}
Future<List<bool>> downloadAll(List<DownloadTask> tasks) {
return _downloader.enqueueAll(tasks);
}
Future<void> deleteAllTrackingRecords() {
return _downloader.database.deleteAllRecords();
}
Future<bool> cancel(String id) {
return _downloader.cancelTaskWithId(id);
}
Future<List<TaskRecord>> getLiveVideoTasks() {
return _downloader.database.allRecordsWithStatus(TaskStatus.complete, group: kDownloadGroupLivePhoto);
}
Future<void> deleteRecordsWithIds(List<String> ids) {
return _downloader.database.deleteRecordsWithIds(ids);
}
Future<List<bool>> downloadAllAssets(List<RemoteAsset> assets) async {
if (assets.isEmpty) {
return Future.value(const []);
}
final length = Platform.isAndroid ? assets.length : assets.length * 2;
final tasks = List.filled(length, _dummyTask);
int taskIndex = 0;
final headers = ApiService.getRequestHeaders();
for (final asset in assets) {
if (!asset.isRemoteOnly) {
continue;
}
final id = asset.id;
final livePhotoVideoId = asset.livePhotoVideoId;
final isVideo = asset.isVideo;
final url = getOriginalUrlForRemoteId(id);
// on iOS it cannot link the image, check if the filename has .MP extension
// to avoid downloading the video part
final isAndroidMotionPhoto = asset.name.contains(".MP");
if (Platform.isAndroid || livePhotoVideoId == null || isVideo || isAndroidMotionPhoto) {
tasks[taskIndex++] = DownloadTask(
taskId: id,
url: url,
headers: headers,
filename: asset.name,
updates: Updates.statusAndProgress,
group: isVideo ? kDownloadGroupVideo : kDownloadGroupImage,
);
continue;
}
_dummyMetadata['part'] = LivePhotosPart.image.index;
_dummyMetadata['id'] = id;
tasks[taskIndex++] = DownloadTask(
taskId: id,
url: url,
headers: headers,
filename: asset.name,
updates: Updates.statusAndProgress,
group: kDownloadGroupLivePhoto,
metaData: json.encode(_dummyMetadata),
);
_dummyMetadata['part'] = LivePhotosPart.video.index;
tasks[taskIndex++] = DownloadTask(
taskId: livePhotoVideoId,
url: getOriginalUrlForRemoteId(livePhotoVideoId),
headers: headers,
filename: asset.name.toUpperCase().replaceAll(RegExp(r"\.(JPG|HEIC)$"), '.MOV'),
updates: Updates.statusAndProgress,
group: kDownloadGroupLivePhoto,
metaData: json.encode(_dummyMetadata),
);
}
if (taskIndex == 0) {
return Future.value(const []);
}
return _downloader.enqueueAll(tasks.slice(0, taskIndex));
}
}

View file

@ -0,0 +1,118 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/api.repository.dart';
// ignore: import_rule_openapi
import 'package:openapi/api.dart';
final driftAlbumApiRepositoryProvider = Provider(
(ref) => DriftAlbumApiRepository(ref.watch(apiServiceProvider).albumsApi),
);
class DriftAlbumApiRepository extends ApiRepository {
final AlbumsApi _api;
DriftAlbumApiRepository(this._api);
Future<RemoteAlbum> createDriftAlbum(String name, {required Iterable<String> assetIds, String? description}) async {
final responseDto = await checkNull(
_api.createAlbum(CreateAlbumDto(albumName: name, description: description, assetIds: assetIds.toList())),
);
return responseDto.toRemoteAlbum();
}
Future<({List<String> removed, List<String> failed})> removeAssets(String albumId, Iterable<String> assetIds) async {
final response = await checkNull(_api.removeAssetFromAlbum(albumId, BulkIdsDto(ids: assetIds.toList())));
final List<String> removed = [], failed = [];
for (final dto in response) {
if (dto.success) {
removed.add(dto.id);
} else {
failed.add(dto.id);
}
}
return (removed: removed, failed: failed);
}
Future<({List<String> added, List<String> failed})> addAssets(String albumId, Iterable<String> assetIds) async {
final response = await checkNull(_api.addAssetsToAlbum(albumId, BulkIdsDto(ids: assetIds.toList())));
final List<String> added = [], failed = [];
for (final dto in response) {
if (dto.success) {
added.add(dto.id);
} else {
failed.add(dto.id);
}
}
return (added: added, failed: failed);
}
Future<RemoteAlbum> updateAlbum(
String albumId, {
String? name,
String? description,
String? thumbnailAssetId,
bool? isActivityEnabled,
AlbumAssetOrder? order,
}) async {
AssetOrder? apiOrder;
if (order != null) {
apiOrder = order == AlbumAssetOrder.asc ? AssetOrder.asc : AssetOrder.desc;
}
final responseDto = await checkNull(
_api.updateAlbumInfo(
albumId,
UpdateAlbumDto(
albumName: name,
description: description,
albumThumbnailAssetId: thumbnailAssetId,
isActivityEnabled: isActivityEnabled,
order: apiOrder,
),
),
);
return responseDto.toRemoteAlbum();
}
Future<void> deleteAlbum(String albumId) {
return _api.deleteAlbum(albumId);
}
Future<RemoteAlbum> addUsers(String albumId, Iterable<String> userIds) async {
final albumUsers = userIds.map((userId) => AlbumUserAddDto(userId: userId)).toList();
final response = await checkNull(_api.addUsersToAlbum(albumId, AddUsersDto(albumUsers: albumUsers)));
return response.toRemoteAlbum();
}
Future<void> removeUser(String albumId, {required String userId}) async {
await _api.removeUserFromAlbum(albumId, userId);
}
Future<bool> setActivityStatus(String albumId, bool isEnabled) async {
final response = await checkNull(_api.updateAlbumInfo(albumId, UpdateAlbumDto(isActivityEnabled: isEnabled)));
return response.isActivityEnabled;
}
}
extension on AlbumResponseDto {
RemoteAlbum toRemoteAlbum() {
return RemoteAlbum(
id: id,
name: albumName,
ownerId: owner.id,
description: description,
createdAt: createdAt,
updatedAt: updatedAt,
thumbnailAssetId: albumThumbnailAssetId,
isActivityEnabled: isActivityEnabled,
order: order == AssetOrder.asc ? AlbumAssetOrder.asc : AlbumAssetOrder.desc,
assetCount: assetCount,
ownerName: owner.name,
isShared: albumUsers.length > 2,
);
}
}

View file

@ -0,0 +1,27 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/etag.entity.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/repositories/database.repository.dart';
import 'package:isar/isar.dart';
final etagRepositoryProvider = Provider((ref) => ETagRepository(ref.watch(dbProvider)));
class ETagRepository extends DatabaseRepository {
const ETagRepository(super.db);
Future<List<String>> getAllIds() => db.eTags.where().idProperty().findAll();
Future<ETag?> get(String id) => db.eTags.getById(id);
Future<void> upsertAll(List<ETag> etags) => txn(() => db.eTags.putAll(etags));
Future<void> deleteByIds(List<String> ids) => txn(() => db.eTags.deleteAllById(ids));
Future<ETag?> getById(String id) => db.eTags.getById(id);
Future<void> clearTable() async {
await txn(() async {
await db.eTags.clear();
});
}
}

View file

@ -0,0 +1,52 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart' hide AssetType;
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:photo_manager/photo_manager.dart' hide AssetType;
final fileMediaRepositoryProvider = Provider((ref) => const FileMediaRepository());
class FileMediaRepository {
const FileMediaRepository();
Future<Asset?> saveImage(Uint8List data, {required String title, String? relativePath}) async {
final entity = await PhotoManager.editor.saveImage(data, filename: title, title: title, relativePath: relativePath);
return AssetMediaRepository.toAsset(entity);
}
Future<LocalAsset?> saveLocalAsset(Uint8List data, {required String title, String? relativePath}) async {
final entity = await PhotoManager.editor.saveImage(data, filename: title, title: title, relativePath: relativePath);
return LocalAsset(
id: entity.id,
name: title,
type: AssetType.image,
createdAt: entity.createDateTime,
updatedAt: entity.modifiedDateTime,
isEdited: false,
);
}
Future<Asset?> saveImageWithFile(String filePath, {String? title, String? relativePath}) async {
final entity = await PhotoManager.editor.saveImageWithPath(filePath, title: title, relativePath: relativePath);
return AssetMediaRepository.toAsset(entity);
}
Future<Asset?> saveLivePhoto({required File image, required File video, required String title}) async {
final entity = await PhotoManager.editor.darwin.saveLivePhoto(imageFile: image, videoFile: video, title: title);
return AssetMediaRepository.toAsset(entity);
}
Future<Asset?> saveVideo(File file, {required String title, String? relativePath}) async {
final entity = await PhotoManager.editor.saveVideo(file, title: title, relativePath: relativePath);
return AssetMediaRepository.toAsset(entity);
}
Future<void> clearFileCache() => PhotoManager.clearFileCache();
Future<void> enableBackgroundAccess() => PhotoManager.setIgnorePermissionCheck(true);
Future<void> requestExtendedPermissions() => PhotoManager.requestPermissionExtend();
}

View file

@ -0,0 +1,35 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/api.repository.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
final folderApiRepositoryProvider = Provider((ref) => FolderApiRepository(ref.watch(apiServiceProvider).viewApi));
class FolderApiRepository extends ApiRepository {
final ViewsApi _api;
final Logger _log = Logger("FolderApiRepository");
FolderApiRepository(this._api);
Future<List<String>> getAllUniquePaths() async {
try {
final list = await _api.getUniqueOriginalPaths();
return list ?? [];
} catch (e, stack) {
_log.severe("Failed to fetch unique original links", e, stack);
return [];
}
}
Future<List<Asset>> getAssetsForPath(String? path) async {
try {
final list = await _api.getAssetsByOriginalPath(path ?? '/');
return list != null ? list.map(Asset.remote).toList() : [];
} catch (e, stack) {
_log.severe("Failed to fetch Assets by original path", e, stack);
return [];
}
}
}

View file

@ -0,0 +1,68 @@
import 'package:cast/device.dart';
import 'package:cast/session.dart';
import 'package:cast/session_manager.dart';
import 'package:cast/discovery_service.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
final gCastRepositoryProvider = Provider((_) {
return GCastRepository();
});
class GCastRepository {
CastSession? _castSession;
void Function(CastSessionState)? onCastStatus;
void Function(Map<String, dynamic>)? onCastMessage;
Map<String, dynamic>? _receiverStatus;
GCastRepository();
Future<void> connect(CastDevice device) async {
_castSession = await CastSessionManager().startSession(device);
_castSession?.stateStream.listen((state) {
onCastStatus?.call(state);
});
_castSession?.messageStream.listen((message) {
onCastMessage?.call(message);
if (message['type'] == 'RECEIVER_STATUS') {
_receiverStatus = message;
}
});
// open the default receiver
sendMessage(CastSession.kNamespaceReceiver, {'type': 'LAUNCH', 'appId': 'CC1AD845'});
}
Future<void> disconnect() async {
final sessionID = getSessionId();
sendMessage(CastSession.kNamespaceReceiver, {'type': "STOP", "sessionId": sessionID});
// wait 500ms to ensure the stop command is processed
await Future.delayed(const Duration(milliseconds: 500));
await _castSession?.close();
}
String? getSessionId() {
if (_receiverStatus == null) {
return null;
}
return _receiverStatus!['status']['applications'][0]['sessionId'];
}
void sendMessage(String namespace, Map<String, dynamic> message) {
if (_castSession == null) {
throw Exception("Cast session is not established");
}
_castSession!.sendMessage(namespace, message);
}
Future<List<CastDevice>> listDestinations() async {
return await CastDiscoveryService().search(timeout: const Duration(seconds: 3));
}
}

View file

@ -0,0 +1,51 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/services/local_files_manager.service.dart';
import 'package:logging/logging.dart';
final localFilesManagerRepositoryProvider = Provider(
(ref) => LocalFilesManagerRepository(ref.watch(localFileManagerServiceProvider)),
);
class LocalFilesManagerRepository {
LocalFilesManagerRepository(this._service);
final Logger _logger = Logger('LocalFilesManagerRepo');
final LocalFilesManagerService _service;
Future<bool> moveToTrash(List<String> mediaUrls) async {
return await _service.moveToTrash(mediaUrls);
}
Future<bool> restoreFromTrash(String fileName, int type) async {
return await _service.restoreFromTrash(fileName, type);
}
Future<bool> requestManageMediaPermission() async {
return await _service.requestManageMediaPermission();
}
Future<bool> hasManageMediaPermission() async {
return await _service.hasManageMediaPermission();
}
Future<bool> manageMediaPermission() async {
return await _service.manageMediaPermission();
}
Future<List<String>> restoreAssetsFromTrash(Iterable<LocalAsset> assets) async {
final restoredIds = <String>[];
for (final asset in assets) {
_logger.info("Restoring from trash, localId: ${asset.id}, remoteId: ${asset.checksum}");
try {
final result = await _service.restoreFromTrashById(asset.id, asset.type.index);
if (result) {
restoredIds.add(asset.id);
}
} catch (e) {
_logger.warning("Restoring failure: $e");
}
}
return restoredIds;
}
}

View file

@ -0,0 +1,34 @@
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:network_info_plus/network_info_plus.dart';
final networkRepositoryProvider = Provider((_) {
final networkInfo = NetworkInfo();
return NetworkRepository(networkInfo);
});
class NetworkRepository {
final NetworkInfo _networkInfo;
const NetworkRepository(this._networkInfo);
Future<String?> getWifiName() {
if (Platform.isAndroid) {
// remove quote around the return value on Android
// https://github.com/fluttercommunity/plus_plugins/tree/main/packages/network_info_plus/network_info_plus#android
return _networkInfo.getWifiName().then((value) {
if (value != null) {
return value.replaceAll(RegExp(r'"'), '');
}
return value;
});
}
return _networkInfo.getWifiName();
}
Future<String?> getWifiIp() {
return _networkInfo.getWifiIP();
}
}

View file

@ -0,0 +1,34 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart' as entity;
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/repositories/database.repository.dart';
import 'package:isar/isar.dart';
final partnerRepositoryProvider = Provider((ref) => PartnerRepository(ref.watch(dbProvider)));
class PartnerRepository extends DatabaseRepository {
const PartnerRepository(super.db);
Future<List<UserDto>> getSharedBy() async {
return (await db.users.filter().isPartnerSharedByEqualTo(true).sortById().findAll()).map((u) => u.toDto()).toList();
}
Future<List<UserDto>> getSharedWith() async {
return (await db.users.filter().isPartnerSharedWithEqualTo(true).sortById().findAll())
.map((u) => u.toDto())
.toList();
}
Stream<List<UserDto>> watchSharedBy() {
return (db.users.filter().isPartnerSharedByEqualTo(true).sortById().watch()).map(
(users) => users.map((u) => u.toDto()).toList(),
);
}
Stream<List<UserDto>> watchSharedWith() {
return (db.users.filter().isPartnerSharedWithEqualTo(true).sortById().watch()).map(
(users) => users.map((u) => u.toDto()).toList(),
);
}
}

View file

@ -0,0 +1,35 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/utils/user.converter.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/api.repository.dart';
import 'package:openapi/api.dart';
enum Direction { sharedWithMe, sharedByMe }
final partnerApiRepositoryProvider = Provider((ref) => PartnerApiRepository(ref.watch(apiServiceProvider).partnersApi));
class PartnerApiRepository extends ApiRepository {
final PartnersApi _api;
PartnerApiRepository(this._api);
Future<List<UserDto>> getAll(Direction direction) async {
final response = await checkNull(
_api.getPartners(direction == Direction.sharedByMe ? PartnerDirection.by : PartnerDirection.with_),
);
return response.map(UserConverter.fromPartnerDto).toList();
}
Future<UserDto> create(String id) async {
final dto = await checkNull(_api.createPartnerDeprecated(id));
return UserConverter.fromPartnerDto(dto);
}
Future<void> delete(String id) => _api.removePartner(id);
Future<UserDto> update(String id, {required bool inTimeline}) async {
final dto = await checkNull(_api.updatePartner(id, PartnerUpdateDto(inTimeline: inTimeline)));
return UserConverter.fromPartnerDto(dto);
}
}

View file

@ -0,0 +1,45 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:permission_handler/permission_handler.dart';
final permissionRepositoryProvider = Provider((_) {
return const PermissionRepository();
});
class PermissionRepository implements IPermissionRepository {
const PermissionRepository();
@override
Future<bool> hasLocationWhenInUsePermission() {
return Permission.locationWhenInUse.isGranted;
}
@override
Future<bool> requestLocationWhenInUsePermission() async {
final result = await Permission.locationWhenInUse.request();
return result.isGranted;
}
@override
Future<bool> hasLocationAlwaysPermission() {
return Permission.locationAlways.isGranted;
}
@override
Future<bool> requestLocationAlwaysPermission() async {
final result = await Permission.locationAlways.request();
return result.isGranted;
}
@override
Future<bool> openSettings() {
return openAppSettings();
}
}
abstract interface class IPermissionRepository {
Future<bool> hasLocationWhenInUsePermission();
Future<bool> requestLocationWhenInUsePermission();
Future<bool> hasLocationAlwaysPermission();
Future<bool> requestLocationAlwaysPermission();
Future<bool> openSettings();
}

View file

@ -0,0 +1,33 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/api.repository.dart';
import 'package:openapi/api.dart';
final personApiRepositoryProvider = Provider((ref) => PersonApiRepository(ref.watch(apiServiceProvider).peopleApi));
class PersonApiRepository extends ApiRepository {
final PeopleApi _api;
PersonApiRepository(this._api);
Future<List<PersonDto>> getAll() async {
final dto = await checkNull(_api.getAllPeople());
return dto.people.map(_toPerson).toList();
}
Future<PersonDto> update(String id, {String? name, DateTime? birthday}) async {
final birthdayUtc = birthday == null ? null : DateTime.utc(birthday.year, birthday.month, birthday.day);
final dto = PersonUpdateDto(name: name, birthDate: birthdayUtc);
final response = await checkNull(_api.updatePerson(id, dto));
return _toPerson(response);
}
static PersonDto _toPerson(PersonResponseDto dto) => PersonDto(
birthDate: dto.birthDate,
id: dto.id,
isHidden: dto.isHidden,
name: dto.name,
thumbnailPath: dto.thumbnailPath,
);
}

View file

@ -0,0 +1,22 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
final secureStorageRepositoryProvider = Provider((ref) => const SecureStorageRepository(FlutterSecureStorage()));
class SecureStorageRepository {
final FlutterSecureStorage _secureStorage;
const SecureStorageRepository(this._secureStorage);
Future<String?> read(String key) {
return _secureStorage.read(key: key);
}
Future<void> write(String key, String value) {
return _secureStorage.write(key: key, value: value);
}
Future<void> delete(String key) {
return _secureStorage.delete(key: key);
}
}

View file

@ -0,0 +1,32 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/sessions/session_create_response.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/api.repository.dart';
import 'package:openapi/api.dart';
final sessionsAPIRepositoryProvider = Provider(
(ref) => SessionsAPIRepository(ref.watch(apiServiceProvider).sessionsApi),
);
class SessionsAPIRepository extends ApiRepository {
final SessionsApi _api;
SessionsAPIRepository(this._api);
Future<SessionCreateResponse> createSession(String deviceType, String deviceOS, {int? duration}) async {
final dto = await checkNull(
_api.createSession(SessionCreateDto(deviceType: deviceType, deviceOS: deviceOS, duration: duration)),
);
return SessionCreateResponse(
id: dto.id,
current: dto.current,
deviceType: deviceType,
deviceOS: deviceOS,
expiresAt: dto.expiresAt,
createdAt: dto.createdAt,
updatedAt: dto.updatedAt,
token: dto.token,
);
}
}

View file

@ -0,0 +1,56 @@
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
import 'package:share_handler/share_handler.dart';
final shareHandlerRepositoryProvider = Provider((ref) => ShareHandlerRepository());
class ShareHandlerRepository {
ShareHandlerRepository();
void Function(List<ShareIntentAttachment> attachments)? onSharedMedia;
Future<void> init() async {
final handler = ShareHandlerPlatform.instance;
final media = await handler.getInitialSharedMedia();
if (media != null && media.attachments != null) {
onSharedMedia?.call(_buildPayload(media.attachments!));
}
handler.sharedMediaStream.listen((SharedMedia media) {
if (media.attachments != null) {
onSharedMedia?.call(_buildPayload(media.attachments!));
}
});
}
List<ShareIntentAttachment> _buildPayload(List<SharedAttachment?> attachments) {
final payload = <ShareIntentAttachment>[];
for (final attachment in attachments) {
if (attachment == null) {
continue;
}
final type = attachment.type == SharedAttachmentType.image
? ShareIntentAttachmentType.image
: ShareIntentAttachmentType.video;
final fileLength = File(attachment.path).lengthSync();
payload.add(
ShareIntentAttachment(
path: attachment.path,
type: type,
status: UploadStatus.enqueued,
uploadProgress: 0.0,
fileLength: fileLength,
),
);
}
return payload;
}
}

View file

@ -0,0 +1,146 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/repositories/database.repository.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:isar/isar.dart';
final timelineRepositoryProvider = Provider((ref) => TimelineRepository(ref.watch(dbProvider)));
class TimelineRepository extends DatabaseRepository {
const TimelineRepository(super.db);
Future<List<String>> getTimelineUserIds(String id) {
return db.users.filter().inTimelineEqualTo(true).or().idEqualTo(id).idProperty().findAll();
}
Stream<List<String>> watchTimelineUsers(String id) {
return db.users.filter().inTimelineEqualTo(true).or().idEqualTo(id).idProperty().watch();
}
Stream<RenderList> watchArchiveTimeline(String userId) {
final query = db.assets
.where()
.ownerIdEqualToAnyChecksum(fastHash(userId))
.filter()
.isTrashedEqualTo(false)
.visibilityEqualTo(AssetVisibilityEnum.archive)
.sortByFileCreatedAtDesc();
return _watchRenderList(query, GroupAssetsBy.none);
}
Stream<RenderList> watchFavoriteTimeline(String userId) {
final query = db.assets
.where()
.ownerIdEqualToAnyChecksum(fastHash(userId))
.filter()
.isFavoriteEqualTo(true)
.not()
.visibilityEqualTo(AssetVisibilityEnum.locked)
.isTrashedEqualTo(false)
.sortByFileCreatedAtDesc();
return _watchRenderList(query, GroupAssetsBy.none);
}
Stream<RenderList> watchAlbumTimeline(Album album, GroupAssetsBy groupAssetByOption) {
final query = album.assets.filter().isTrashedEqualTo(false).not().visibilityEqualTo(AssetVisibilityEnum.locked);
final withSortedOption = switch (album.sortOrder) {
SortOrder.asc => query.sortByFileCreatedAt(),
SortOrder.desc => query.sortByFileCreatedAtDesc(),
};
return _watchRenderList(withSortedOption, groupAssetByOption);
}
Stream<RenderList> watchTrashTimeline(String userId) {
final query = db.assets.filter().ownerIdEqualTo(fastHash(userId)).isTrashedEqualTo(true).sortByFileCreatedAtDesc();
return _watchRenderList(query, GroupAssetsBy.none);
}
Stream<RenderList> watchAllVideosTimeline(String userId) {
final query = db.assets
.where()
.ownerIdEqualToAnyChecksum(fastHash(userId))
.filter()
.isTrashedEqualTo(false)
.visibilityEqualTo(AssetVisibilityEnum.timeline)
.typeEqualTo(AssetType.video)
.sortByFileCreatedAtDesc();
return _watchRenderList(query, GroupAssetsBy.none);
}
Stream<RenderList> watchHomeTimeline(String userId, GroupAssetsBy groupAssetByOption) {
final query = db.assets
.where()
.ownerIdEqualToAnyChecksum(fastHash(userId))
.filter()
.isTrashedEqualTo(false)
.stackPrimaryAssetIdIsNull()
.visibilityEqualTo(AssetVisibilityEnum.timeline)
.sortByFileCreatedAtDesc();
return _watchRenderList(query, groupAssetByOption);
}
Stream<RenderList> watchMultiUsersTimeline(List<String> userIds, GroupAssetsBy groupAssetByOption) {
final isarUserIds = userIds.map(fastHash).toList();
final query = db.assets
.where()
.anyOf(isarUserIds, (qb, id) => qb.ownerIdEqualToAnyChecksum(id))
.filter()
.isTrashedEqualTo(false)
.visibilityEqualTo(AssetVisibilityEnum.timeline)
.stackPrimaryAssetIdIsNull()
.sortByFileCreatedAtDesc();
return _watchRenderList(query, groupAssetByOption);
}
Future<RenderList> getTimelineFromAssets(List<Asset> assets, GroupAssetsBy getGroupByOption) {
return RenderList.fromAssets(assets, getGroupByOption);
}
Stream<RenderList> watchAssetSelectionTimeline(String userId) {
final query = db.assets
.where()
.remoteIdIsNotNull()
.filter()
.ownerIdEqualTo(fastHash(userId))
.visibilityEqualTo(AssetVisibilityEnum.timeline)
.isTrashedEqualTo(false)
.stackPrimaryAssetIdIsNull()
.sortByFileCreatedAtDesc();
return _watchRenderList(query, GroupAssetsBy.none);
}
Stream<RenderList> watchLockedTimeline(String userId, GroupAssetsBy getGroupByOption) {
final query = db.assets
.where()
.ownerIdEqualToAnyChecksum(fastHash(userId))
.filter()
.visibilityEqualTo(AssetVisibilityEnum.locked)
.isTrashedEqualTo(false)
.sortByFileCreatedAtDesc();
return _watchRenderList(query, getGroupByOption);
}
Stream<RenderList> _watchRenderList(
QueryBuilder<Asset, Asset, QAfterSortBy> query,
GroupAssetsBy groupAssetsBy,
) async* {
yield await RenderList.fromQuery(query, groupAssetsBy);
await for (final _ in query.watchLazy()) {
yield await RenderList.fromQuery(query, groupAssetsBy);
}
}
}

View file

@ -0,0 +1,207 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:cancellation_token_http/http.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:logging/logging.dart';
import 'package:immich_mobile/utils/debug_print.dart';
class UploadTaskWithFile {
final File file;
final UploadTask task;
const UploadTaskWithFile({required this.file, required this.task});
}
final uploadRepositoryProvider = Provider((ref) => UploadRepository());
class UploadRepository {
final Logger logger = Logger('UploadRepository');
void Function(TaskStatusUpdate)? onUploadStatus;
void Function(TaskProgressUpdate)? onTaskProgress;
UploadRepository() {
FileDownloader().registerCallbacks(
group: kBackupGroup,
taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
FileDownloader().registerCallbacks(
group: kBackupLivePhotoGroup,
taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
FileDownloader().registerCallbacks(
group: kManualUploadGroup,
taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
}
Future<void> enqueueBackground(UploadTask task) {
return FileDownloader().enqueue(task);
}
Future<List<bool>> enqueueBackgroundAll(List<UploadTask> tasks) {
return FileDownloader().enqueueAll(tasks);
}
Future<void> deleteDatabaseRecords(String group) {
return FileDownloader().database.deleteAllRecords(group: group);
}
Future<bool> cancelAll(String group) {
return FileDownloader().cancelAll(group: group);
}
Future<int> reset(String group) {
return FileDownloader().reset(group: group);
}
/// Get a list of tasks that are ENQUEUED or RUNNING
Future<List<Task>> getActiveTasks(String group) {
return FileDownloader().allTasks(group: group);
}
Future<void> start() {
return FileDownloader().start();
}
Future<void> getUploadInfo() async {
final [enqueuedTasks, runningTasks, canceledTasks, waitingTasks, pausedTasks] = await Future.wait([
FileDownloader().database.allRecordsWithStatus(TaskStatus.enqueued, group: kBackupGroup),
FileDownloader().database.allRecordsWithStatus(TaskStatus.running, group: kBackupGroup),
FileDownloader().database.allRecordsWithStatus(TaskStatus.canceled, group: kBackupGroup),
FileDownloader().database.allRecordsWithStatus(TaskStatus.waitingToRetry, group: kBackupGroup),
FileDownloader().database.allRecordsWithStatus(TaskStatus.paused, group: kBackupGroup),
]);
dPrint(
() =>
"""
Upload Info:
Enqueued: ${enqueuedTasks.length}
Running: ${runningTasks.length}
Canceled: ${canceledTasks.length}
Waiting: ${waitingTasks.length}
Paused: ${pausedTasks.length}
""",
);
}
Future<UploadResult> uploadFile({
required File file,
required String originalFileName,
required Map<String, String> headers,
required Map<String, String> fields,
required Client httpClient,
required CancellationToken cancelToken,
required void Function(int bytes, int totalBytes) onProgress,
required String logContext,
}) async {
final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
try {
final fileStream = file.openRead();
final assetRawUploadData = MultipartFile("assetData", fileStream, file.lengthSync(), filename: originalFileName);
final baseRequest = _CustomMultipartRequest('POST', Uri.parse('$savedEndpoint/assets'), onProgress: onProgress);
baseRequest.headers.addAll(headers);
baseRequest.fields.addAll(fields);
baseRequest.files.add(assetRawUploadData);
final response = await httpClient.send(baseRequest, cancellationToken: cancelToken);
final responseBodyString = await response.stream.bytesToString();
if (![200, 201].contains(response.statusCode)) {
String? errorMessage;
if (response.statusCode == 413) {
errorMessage = 'Error(413) File is too large to upload';
return UploadResult.error(statusCode: response.statusCode, errorMessage: errorMessage);
}
try {
final error = jsonDecode(responseBodyString);
errorMessage = error['message'] ?? error['error'];
} catch (_) {
errorMessage = responseBodyString.isNotEmpty
? responseBodyString
: 'Upload failed with status ${response.statusCode}';
}
return UploadResult.error(statusCode: response.statusCode, errorMessage: errorMessage);
}
try {
final responseBody = jsonDecode(responseBodyString);
return UploadResult.success(remoteAssetId: responseBody['id'] as String);
} catch (e) {
return UploadResult.error(errorMessage: 'Failed to parse server response');
}
} on CancelledException {
logger.warning("Upload $logContext was cancelled");
return UploadResult.cancelled();
} catch (error, stackTrace) {
logger.warning("Error uploading $logContext: ${error.toString()}: $stackTrace");
return UploadResult.error(errorMessage: error.toString());
}
}
}
class UploadResult {
final bool isSuccess;
final bool isCancelled;
final String? remoteAssetId;
final String? errorMessage;
final int? statusCode;
const UploadResult({
required this.isSuccess,
required this.isCancelled,
this.remoteAssetId,
this.errorMessage,
this.statusCode,
});
factory UploadResult.success({required String remoteAssetId}) {
return UploadResult(isSuccess: true, isCancelled: false, remoteAssetId: remoteAssetId);
}
factory UploadResult.error({String? errorMessage, int? statusCode}) {
return UploadResult(isSuccess: false, isCancelled: false, errorMessage: errorMessage, statusCode: statusCode);
}
factory UploadResult.cancelled() {
return const UploadResult(isSuccess: false, isCancelled: true);
}
}
class _CustomMultipartRequest extends MultipartRequest {
_CustomMultipartRequest(super.method, super.url, {required this.onProgress});
final void Function(int bytes, int totalBytes) onProgress;
@override
ByteStream finalize() {
final byteStream = super.finalize();
final total = contentLength;
var bytes = 0;
final t = StreamTransformer.fromHandlers(
handleData: (List<int> data, EventSink<List<int>> sink) {
bytes += data.length;
onProgress.call(bytes, total);
sink.add(data);
},
);
final stream = byteStream.transform(t);
return ByteStream(stream);
}
}

View file

@ -0,0 +1,20 @@
import 'package:home_widget/home_widget.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
final widgetRepositoryProvider = Provider((_) => const WidgetRepository());
class WidgetRepository {
const WidgetRepository();
Future<void> saveData(String key, String value) async {
await HomeWidget.saveWidgetData<String>(key, value);
}
Future<void> refresh(String iosName, String androidName) async {
await HomeWidget.updateWidget(iOSName: iosName, qualifiedAndroidName: androidName);
}
Future<void> setAppGroupId(String appGroupId) async {
await HomeWidget.setAppGroupId(appGroupId);
}
}