pull/28280/merge
Peter Ombodi 2026-05-29 17:23:46 -07:00 committed by GitHub
commit 8b4f6ff11d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
60 changed files with 17991 additions and 974 deletions

View File

@ -465,10 +465,15 @@
"advanced_settings_proxy_headers_title": "Custom proxy headers [EXPERIMENTAL]",
"advanced_settings_readonly_mode_subtitle": "Enables the read-only mode where the photos can be only viewed, things like selecting multiple images, sharing, casting, delete are all disabled. Enable/Disable read-only via user avatar from the main screen",
"advanced_settings_readonly_mode_title": "Read-only mode",
"advanced_settings_review_remote_deletions_subtitle": "Manually review cloud trash changes. ",
"advanced_settings_review_remote_deletions_subtitle_android": "Restorations are applied automatically when Media Management Access is allowed.",
"advanced_settings_review_remote_deletions_title": "Review remote deletions",
"advanced_settings_self_signed_ssl_subtitle": "Skips SSL certificate verification for the server endpoint. Required for self-signed certificates.",
"advanced_settings_self_signed_ssl_title": "Allow self-signed SSL certificates [EXPERIMENTAL]",
"advanced_settings_sync_remote_deletions_subtitle": "Automatically delete or restore an asset on this device when that action is taken on the web",
"advanced_settings_sync_remote_deletions_title": "Sync remote deletions [EXPERIMENTAL]",
"advanced_settings_sync_remote_deletions_off_subtitle": "Cloud trash changes are ignored",
"advanced_settings_sync_remote_deletions_selector_title": "Sync remote deletions [EXPERIMENTAL]",
"advanced_settings_sync_remote_deletions_subtitle": "Automatically move assets to trash or restore them on this device when that action is taken on the web.",
"advanced_settings_sync_remote_deletions_title": "Auto sync",
"advanced_settings_tile_subtitle": "Advanced user's settings",
"advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting",
"advanced_settings_troubleshooting_title": "Troubleshooting",
@ -579,6 +584,11 @@
"asset_not_found_on_icloud": "Asset not found on iCloud. the asset may be inaccessible due to bad file stored on iCloud",
"asset_offline": "Asset Offline",
"asset_offline_description": "This external asset is no longer found on disk. Please contact your Immich administrator for help.",
"asset_out_of_sync_title": "Out-of-sync assets list",
"asset_out_of_sync_trash_confirmation_text": "Move {count, plural, one {asset} other {# assets}} to your device trash?",
"asset_out_of_sync_trash_confirmation_title": "Sync trash change",
"asset_out_of_sync_trash_subtitle": "Assets moved to the Immich cloud trash: choose to move them to local trash or keep them on this device.",
"asset_out_of_sync_trash_subtitle_result": "Nothing left to review — all decisions applied.",
"asset_restored_successfully": "Asset restored successfully",
"asset_skipped": "Skipped",
"asset_skipped_in_trash": "In trash",
@ -597,6 +607,7 @@
"assets_count": "{count, plural, one {# asset} other {# assets}}",
"assets_deleted_permanently": "{count} asset(s) deleted permanently",
"assets_deleted_permanently_from_server": "{count} asset(s) deleted permanently from the Immich server",
"assets_denied_to_moved_to_trash_count": "Keeping local copies of {count, plural, one {# asset} other {# assets}}",
"assets_downloaded_failed": "{count, plural, one {Downloaded # file - {error} file failed} other {Downloaded # files - {error} files failed}}",
"assets_downloaded_successfully": "{count, plural, one {Downloaded # file successfully} other {Downloaded # files successfully}}",
"assets_moved_to_trash_count": "Moved {count, plural, one {# asset} other {# assets}} to trash",
@ -1371,6 +1382,7 @@
"keep_all": "Keep All",
"keep_description": "Choose what stays on your device when freeing up space.",
"keep_favorites": "Keep favorites",
"keep_in_trash": "Keep in trash",
"keep_on_device": "Keep on device",
"keep_on_device_hint": "Select items to keep on this device",
"keep_this_delete_others": "Keep this, delete others",
@ -1498,6 +1510,7 @@
"make": "Make",
"manage_geolocation": "Manage location",
"manage_media_access_rationale": "This permission is required for proper handling of moving assets to the trash and restoring them from it.",
"manage_media_access_review_rationale": "Review mode remains enabled without this permission, but moving assets to the trash will ask for additional confirmation and restoring assets will not work.",
"manage_media_access_settings": "Open settings",
"manage_media_access_subtitle": "Allow the Immich app to manage and move media files.",
"manage_media_access_title": "Media Management Access",
@ -1690,6 +1703,7 @@
"obtainium_configurator": "Obtainium Configurator",
"obtainium_configurator_instructions": "Use Obtainium to install and update the Android app directly from Immich GitHub's release. Create an API key and select a variant to create your Obtainium configuration link",
"ocr": "OCR",
"off": "Off",
"official_immich_resources": "Official Immich Resources",
"offline": "Offline",
"offset": "Offset",
@ -1964,6 +1978,7 @@
"retry_upload": "Retry upload",
"review_duplicates": "Review duplicates",
"review_large_files": "Review large files",
"review_out_of_sync_changes": "Review out-of-sync changes",
"role": "Role",
"role_editor": "Editor",
"role_viewer": "Viewer",

File diff suppressed because it is too large Load Diff

View File

@ -23,6 +23,7 @@ class RemoteAsset extends BaseAsset {
required super.createdAt,
required super.updatedAt,
this.uploadedAt,
this.deletedAt,
super.width,
super.height,
super.durationMs,
@ -32,7 +33,6 @@ class RemoteAsset extends BaseAsset {
super.livePhotoVideoId,
this.stackId,
required super.isEdited,
this.deletedAt,
}) : localAssetId = localId;
@override
@ -48,7 +48,7 @@ class RemoteAsset extends BaseAsset {
String get heroTag => '${localId ?? checksum}_$id';
@override
bool get isEditable => isImage && !isMotionPhoto && !isAnimatedImage;
bool get isEditable => isImage && !isMotionPhoto && !isAnimatedImage && deletedAt == null;
bool get isTrashed => deletedAt != null;
@ -62,6 +62,7 @@ class RemoteAsset extends BaseAsset {
createdAt: $createdAt,
updatedAt: $updatedAt,
uploadedAt: ${uploadedAt ?? "<NA>"},
deletedAt: ${deletedAt ?? "<NA>"},
width: ${width ?? "<NA>"},
height: ${height ?? "<NA>"},
durationMs: ${durationMs ?? "<NA>"},
@ -89,6 +90,7 @@ class RemoteAsset extends BaseAsset {
ownerId == other.ownerId &&
thumbHash == other.thumbHash &&
visibility == other.visibility &&
deletedAt == other.deletedAt &&
stackId == other.stackId &&
uploadedAt == other.uploadedAt &&
deletedAt == other.deletedAt;
@ -102,6 +104,7 @@ class RemoteAsset extends BaseAsset {
localId.hashCode ^
thumbHash.hashCode ^
visibility.hashCode ^
deletedAt.hashCode ^
stackId.hashCode ^
uploadedAt.hashCode ^
deletedAt.hashCode;
@ -116,6 +119,7 @@ class RemoteAsset extends BaseAsset {
DateTime? createdAt,
DateTime? updatedAt,
DateTime? uploadedAt,
DateTime? deletedAt,
int? width,
int? height,
int? durationMs,
@ -125,7 +129,6 @@ class RemoteAsset extends BaseAsset {
String? livePhotoVideoId,
String? stackId,
bool? isEdited,
DateTime? deletedAt,
}) {
return RemoteAsset(
id: id ?? this.id,
@ -137,6 +140,7 @@ class RemoteAsset extends BaseAsset {
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
uploadedAt: uploadedAt ?? this.uploadedAt,
deletedAt: deletedAt ?? this.deletedAt,
width: width ?? this.width,
height: height ?? this.height,
durationMs: durationMs ?? this.durationMs,
@ -146,7 +150,6 @@ class RemoteAsset extends BaseAsset {
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
stackId: stackId ?? this.stackId,
isEdited: isEdited ?? this.isEdited,
deletedAt: deletedAt ?? this.deletedAt,
);
}
}

View File

@ -0,0 +1,8 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
class RemoteDeletedLocalAsset {
final LocalAsset asset;
final DateTime remoteDeletedAt;
const RemoteDeletedLocalAsset({required this.asset, required this.remoteDeletedAt});
}

View File

@ -10,10 +10,12 @@ import 'package:immich_mobile/domain/models/config/network_config.dart';
import 'package:immich_mobile/domain/models/config/slideshow_config.dart';
import 'package:immich_mobile/domain/models/config/theme_config.dart';
import 'package:immich_mobile/domain/models/config/timeline_config.dart';
import 'package:immich_mobile/domain/models/config/trash_sync_config.dart';
import 'package:immich_mobile/domain/models/config/viewer_config.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/models/trash_sync.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
const defaultConfig = AppConfig();
@ -30,6 +32,7 @@ class AppConfig {
final AlbumConfig album;
final BackupConfig backup;
final NetworkConfig network;
final TrashSyncConfig trashSync;
const AppConfig({
this.logLevel = .info,
@ -43,6 +46,7 @@ class AppConfig {
this.album = const .new(),
this.backup = const .new(),
this.network = const .new(),
this.trashSync = const .new(),
});
AppConfig copyWith({
@ -57,6 +61,7 @@ class AppConfig {
AlbumConfig? album,
BackupConfig? backup,
NetworkConfig? network,
TrashSyncConfig? trashSync,
}) => .new(
logLevel: logLevel ?? this.logLevel,
theme: theme ?? this.theme,
@ -69,6 +74,7 @@ class AppConfig {
album: album ?? this.album,
backup: backup ?? this.backup,
network: network ?? this.network,
trashSync: trashSync ?? this.trashSync,
);
@override
@ -85,15 +91,16 @@ class AppConfig {
other.slideshow == slideshow &&
other.album == album &&
other.backup == backup &&
other.network == network);
other.network == network &&
other.trashSync == trashSync);
@override
int get hashCode =>
Object.hash(logLevel, theme, cleanup, map, timeline, image, viewer, slideshow, album, backup, network);
Object.hash(logLevel, theme, cleanup, map, timeline, image, viewer, slideshow, album, backup, network, trashSync);
@override
String toString() =>
'AppConfig(logLevel: $logLevel, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network)';
'AppConfig(logLevel: $logLevel, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network, trashSync: $trashSync)';
T read<T extends Object>(MetadataKey<T> key) =>
(switch (key) {
@ -140,6 +147,7 @@ class AppConfig {
.slideshowDuration => slideshow.duration,
.slideshowLook => slideshow.look,
.slideshowDirection => slideshow.direction,
.trashSyncMode => trashSync.mode,
})
as T;
@ -191,6 +199,7 @@ class AppConfig {
.slideshowDuration => copyWith(slideshow: slideshow.copyWith(duration: value as int)),
.slideshowLook => copyWith(slideshow: slideshow.copyWith(look: value as SlideshowLook)),
.slideshowDirection => copyWith(slideshow: slideshow.copyWith(direction: value as SlideshowDirection)),
.trashSyncMode => copyWith(trashSync: trashSync.copyWith(mode: value as TrashSyncMode)),
};
}
}

View File

@ -0,0 +1,18 @@
import 'package:immich_mobile/domain/models/trash_sync.model.dart';
class TrashSyncConfig {
final TrashSyncMode mode;
const TrashSyncConfig({this.mode = TrashSyncMode.off});
TrashSyncConfig copyWith({TrashSyncMode? mode}) => .new(mode: mode ?? this.mode);
@override
bool operator ==(Object other) => identical(this, other) || (other is TrashSyncConfig && other.mode == mode);
@override
int get hashCode => mode.hashCode;
@override
String toString() => 'TrashSyncConfig(mode: $mode)';
}

View File

@ -5,6 +5,7 @@ import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/models/trash_sync.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
enum MetadataKey<T extends Object> {
@ -44,6 +45,9 @@ enum MetadataKey<T extends Object> {
backupTriggerDelay<int>(),
backupSyncAlbums<bool>(),
// Trash sync
trashSyncMode<TrashSyncMode>(codec: _EnumCodec(TrashSyncMode.values)),
// Timeline
timelineTilesPerRow<int>(),
timelineGroupAssetsBy<GroupAssetsBy>(codec: _EnumCodec(GroupAssetsBy.values)),

View File

@ -18,6 +18,9 @@ enum StoreKey<T> {
syncMigrationStatus<String>._(1013),
reviewRemoteDeletions<bool>._(1014),
trashSyncLastCleanup<int>._(1015),
// Legacy keys that have been migrated to the new metadata store
legacyBackupRequireCharging<bool>._(7),
legacyBackupTriggerDelay<int>._(8),

View File

@ -0,0 +1 @@
enum TrashSyncMode { off, autoSync, review }

View File

@ -4,11 +4,12 @@ import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/domain/models/trash_sync.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
@ -25,6 +26,8 @@ class LocalSyncService {
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final AssetMediaRepository _assetMediaRepository;
final IPermissionRepository _permissionRepository;
final DriftTrashSyncRepository _trashSyncRepository;
final MetadataRepository _metadataRepository;
final Logger _log = Logger("DeviceSyncService");
LocalSyncService({
@ -34,18 +37,15 @@ class LocalSyncService {
required this._trashedLocalAssetRepository,
required this._assetMediaRepository,
required this._permissionRepository,
required this._trashSyncRepository,
required this._metadataRepository,
});
Future<void> sync({bool full = false}) async {
final Stopwatch stopwatch = Stopwatch()..start();
try {
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
final hasPermission = await _permissionRepository.hasManageMediaPermission();
if (hasPermission) {
await _syncTrashedAssets();
} else {
_log.warning("syncTrashedAssets cannot proceed because MANAGE_MEDIA permission is missing");
}
if (CurrentPlatform.isAndroid) {
await _syncTrashedAssets();
}
if (CurrentPlatform.isIOS) {
@ -55,7 +55,9 @@ class LocalSyncService {
if (full || await _nativeSyncApi.shouldFullSync()) {
_log.fine("Full sync request from ${full ? "user" : "native"}");
return await fullSync();
await fullSync();
await _cleanupTrashSync();
return;
}
final delta = await _nativeSyncApi.getMediaChanges();
@ -101,6 +103,7 @@ class LocalSyncService {
await _mapIosCloudIds(newAssets);
}
await _cleanupTrashSync();
await _nativeSyncApi.checkpointSync();
} catch (e, s) {
_log.severe("Error performing device sync", e, s);
@ -110,6 +113,13 @@ class LocalSyncService {
}
}
Future<void> _cleanupTrashSync() async {
final deleted = await _trashSyncRepository.cleanupLocalTrashSync();
if (deleted > 0) {
_log.info("cleanup TrashSync, deleted: $deleted");
}
}
Future<void> fullSync() async {
try {
final Stopwatch stopwatch = Stopwatch()..start();
@ -366,30 +376,28 @@ class LocalSyncService {
_log.fine("syncTrashedAssets, trashedAssets: ${trashedAssets.map((e) => e.asset.id)}");
await _trashedLocalAssetRepository.processTrashSnapshot(trashedAssets);
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
if (assetsToRestore.isNotEmpty) {
final restoredIds = await _assetMediaRepository.restoreAssetsFromTrash(assetsToRestore);
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
} else {
_log.info("syncTrashedAssets, No remote assets found for restoration");
}
final localAssetsToTrash = await _trashedLocalAssetRepository.getToTrash();
if (localAssetsToTrash.isNotEmpty) {
final localIds = localAssetsToTrash.values.expand((assets) => assets).map((asset) => asset.id).toList();
_log.info("Moving to trash ${localIds.join(", ")} assets");
final movedIds = await _assetMediaRepository.deleteAll(localIds);
if (movedIds.isNotEmpty) {
final movedAssetsByAlbum = localAssetsToTrash.map(
(albumId, assets) => MapEntry(albumId, assets.where((asset) => movedIds.contains(asset.id)).toList()),
)..removeWhere((_, assets) => assets.isEmpty);
await _trashedLocalAssetRepository.trashLocalAsset(movedAssetsByAlbum);
final trashSyncMode = _metadataRepository.appConfig.trashSync.mode;
if (trashSyncMode != TrashSyncMode.off) {
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
if (assetsToRestore.isEmpty) {
_log.info("syncTrashedAssets, No remote assets found for restoration");
return;
}
if (await _hasManageMediaPermission("restore from trash")) {
final restoredIds = await _assetMediaRepository.restoreAssetsFromTrash(assetsToRestore);
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
}
} else {
_log.info("syncTrashedAssets, No assets found in backup-enabled albums for move to trash");
}
}
Future<bool> _hasManageMediaPermission(String logContext) async {
final hasPermission = await _permissionRepository.hasManageMediaPermission();
if (!hasPermission) {
_log.warning("syncTrashedAssets $logContext cannot proceed because MANAGE_MEDIA permission is missing");
}
return hasPermission;
}
}
extension on Iterable<PlatformAlbum> {

View File

@ -3,18 +3,14 @@
import 'dart:async';
import 'dart:convert';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:collection/collection.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/domain/services/trash_sync.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:logging/logging.dart';
@ -32,10 +28,7 @@ class SyncStreamService {
final SyncApiRepository _syncApiRepository;
final SyncStreamRepository _syncStreamRepository;
final DriftLocalAssetRepository _localAssetRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final AssetMediaRepository _assetMediaRepository;
final IPermissionRepository _permissionRepository;
final TrashSyncService _trashSyncService;
final SyncMigrationRepository _syncMigrationRepository;
final ApiService _api;
final bool Function()? _cancelChecker;
@ -43,13 +36,10 @@ class SyncStreamService {
SyncStreamService({
required this._syncApiRepository,
required this._syncStreamRepository,
required this._localAssetRepository,
required this._trashedLocalAssetRepository,
required this._assetMediaRepository,
required this._permissionRepository,
required this._syncMigrationRepository,
required this._api,
this._cancelChecker,
required this._trashSyncService,
});
bool get isCancelled => _cancelChecker?.call() ?? false;
@ -190,24 +180,22 @@ class SyncStreamService {
case SyncEntityType.partnerDeleteV1:
return _syncStreamRepository.deletePartnerV1(data.cast());
case SyncEntityType.assetV1:
final remoteSyncAssets = data.cast<SyncAssetV1>();
final remoteSyncAssets = data.cast<SyncAssetV1>().toList();
await _syncStreamRepository.updateAssetsV1(remoteSyncAssets);
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList());
}
await _trashSyncService.syncRemoteTrashState(remoteSyncAssets.map((e) => (id: e.id, deletedAt: e.deletedAt)));
return;
case SyncEntityType.assetV2:
final remoteSyncAssets = data.cast<SyncAssetV2>();
final remoteSyncAssets = data.cast<SyncAssetV2>().toList();
await _syncStreamRepository.updateAssetsV2(remoteSyncAssets);
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList());
}
await _trashSyncService.syncRemoteTrashState(remoteSyncAssets.map((e) => (id: e.id, deletedAt: e.deletedAt)));
return;
case SyncEntityType.assetDeleteV1:
final remoteSyncAssets = data.cast<SyncAssetDeleteV1>();
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
await _syncAssetDeletion(remoteSyncAssets.map((e) => e.assetId).toList());
}
final remoteSyncAssets = data.cast<SyncAssetDeleteV1>().toList();
final now = DateTime.now();
final remoteDeletedAtByRemoteId = Map<String, DateTime>.fromEntries(
remoteSyncAssets.map((e) => MapEntry(e.assetId, now)),
);
await _trashSyncService.applyRemoteRemovalToLocal(remoteDeletedAtByRemoteId);
return _syncStreamRepository.deleteAssetsV1(remoteSyncAssets);
case SyncEntityType.assetExifV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast());
@ -477,59 +465,4 @@ class SyncStreamService {
_logger.severe("Error processing AssetEditReadyV2 websocket event", error, stackTrace);
}
}
Future<void> _handleRemoteDeleted(Iterable<String> remoteIds) async {
if (remoteIds.isEmpty) {
return Future.value();
} else {
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(remoteIds);
if (localAssetsToTrash.isNotEmpty) {
await _trashLocalAssets(localAssetsToTrash);
} else {
_logger.info("No assets found in backup-enabled albums for remote assets: $remoteIds");
}
}
}
Future<void> _trashLocalAssets(Map<String, List<LocalAsset>> localAssetsToTrash) async {
final localIds = localAssetsToTrash.values.expand((assets) => assets).map((asset) => asset.id).toList();
_logger.info("Moving to trash ${localIds.join(", ")} assets");
final movedIds = await _assetMediaRepository.deleteAll(localIds);
if (movedIds.isNotEmpty) {
final movedAssetsByAlbum = localAssetsToTrash.map(
(albumId, assets) => MapEntry(albumId, assets.where((asset) => movedIds.contains(asset.id)).toList()),
)..removeWhere((_, assets) => assets.isEmpty);
await _trashedLocalAssetRepository.trashLocalAsset(movedAssetsByAlbum);
}
}
Future<void> _applyRemoteRestoreToLocal() async {
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
if (assetsToRestore.isNotEmpty) {
final restoredIds = await _assetMediaRepository.restoreAssetsFromTrash(assetsToRestore);
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
} else {
_logger.info("No remote assets found for restoration");
}
}
Future<void> _syncAssetTrashStatus(List<String> remoteIds) async {
if (!(await _permissionRepository.hasManageMediaPermission())) {
_logger.warning("Syncing asset trash status cannot proceed because MANAGE_MEDIA permission is missing");
return;
}
await _handleRemoteDeleted(remoteIds);
await _applyRemoteRestoreToLocal();
}
Future<void> _syncAssetDeletion(List<String> remoteIds) async {
if (!(await _permissionRepository.hasManageMediaPermission())) {
_logger.warning("Syncing asset deletion cannot proceed because MANAGE_MEDIA permission is missing");
return;
}
await _handleRemoteDeleted(remoteIds);
}
}

View File

@ -35,6 +35,7 @@ enum TimelineOrigin {
albumActivities,
folder,
recentlyAdded,
syncTrash,
}
class TimelineFactory {
@ -65,6 +66,8 @@ class TimelineFactory {
TimelineService trash(String userId) => TimelineService(_timelineRepository.trash(userId, groupBy));
TimelineService toTrashSyncReview() => TimelineService(_timelineRepository.toTrashSyncReview(groupBy));
TimelineService archive(String userId) => TimelineService(_timelineRepository.archived(userId, groupBy));
TimelineService lockedFolder(String userId) => TimelineService(_timelineRepository.locked(userId, groupBy));

View File

@ -0,0 +1,131 @@
import 'package:immich_mobile/domain/models/asset/remote_deleted_local_asset.model.dart';
import 'package:immich_mobile/domain/models/trash_sync.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:logging/logging.dart';
typedef RemoteAssetTrashState = ({String id, DateTime? deletedAt});
class TrashSyncService {
final Logger _logger = Logger('TrashSyncService');
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final DriftTrashSyncRepository _trashSyncRepository;
final AssetMediaRepository _assetMediaRepository;
final IPermissionRepository _permissionRepository;
final MetadataRepository _metadataRepository;
TrashSyncService({
required this._trashedLocalAssetRepository,
required this._trashSyncRepository,
required this._assetMediaRepository,
required this._permissionRepository,
required this._metadataRepository,
});
TrashSyncMode get mode => _metadataRepository.appConfig.trashSync.mode;
bool get isAutoRestoreEnabled =>
CurrentPlatform.isAndroid && (mode == TrashSyncMode.autoSync || mode == TrashSyncMode.review);
Stream<int> watchPendingApprovalAssetCount() => _trashSyncRepository.watchPendingApprovalAssetCount();
Stream<bool> watchIsAssetApprovalPending(String checksum) =>
_trashSyncRepository.watchIsAssetApprovalPending(checksum);
Future<void> syncRemoteTrashState(Iterable<RemoteAssetTrashState> remoteSyncAssets) async {
final remoteDeletedAtByRemoteId = Map<String, DateTime>.fromEntries(
remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => MapEntry(e.id, e.deletedAt!)),
);
await applyRemoteRemovalToLocal(remoteDeletedAtByRemoteId);
final deletedOutdated = await _trashSyncRepository.deleteOutdated(
remoteSyncAssets.where((e) => e.deletedAt == null).map((e) => e.id),
);
if (deletedOutdated > 0) {
_logger.info("syncTrashedAssets, outdated deleted: $deletedOutdated");
}
if (isAutoRestoreEnabled) {
await _applyRemoteRestoreToLocal();
}
}
Future<void> applyRemoteRemovalToLocal(Map<String, DateTime> remoteDeletedAtByRemoteId) async {
if (remoteDeletedAtByRemoteId.isEmpty) {
return;
}
final remoteTrashCandidates = await _trashSyncRepository.getRemoteTrashCandidates(remoteDeletedAtByRemoteId);
if (remoteTrashCandidates.isEmpty) {
_logger.info("No local assets found for remote assets: $remoteDeletedAtByRemoteId");
return;
}
final unresolvedCandidates = await _tryAutoTrashLocalAssets(remoteTrashCandidates);
if (unresolvedCandidates.isNotEmpty) {
_logger.info(
"Apply remote trash action to review for: ${unresolvedCandidates.map((e) => 'id:${e.asset.id}, name:${e.asset.name}').join('*')}",
);
await _trashSyncRepository.upsertReviewCandidates(unresolvedCandidates);
}
}
Future<void> _applyRemoteRestoreToLocal() async {
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
if (assetsToRestore.isEmpty) {
_logger.fine("No remote assets found for restoration");
return;
}
if (!await _hasManageMediaPermission("restore from trash")) {
return;
}
final restoredIds = await _assetMediaRepository.restoreAssetsFromTrash(assetsToRestore);
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
}
Future<List<RemoteDeletedLocalAsset>> _tryAutoTrashLocalAssets(List<RemoteDeletedLocalAsset> candidates) async {
if (!await _canMoveLocalMediaToTrash()) {
return candidates;
}
final selectedMoveCandidates = await _trashSyncRepository.getSelectedBackupRemoteTrashMoveCandidates(candidates);
if (selectedMoveCandidates.isEmpty) {
return candidates;
}
final localIds = selectedMoveCandidates.map((item) => item.candidate.asset.id).toSet().toList();
_logger.info("Moving to trash ${localIds.join(", ")} assets");
final movedIds = await _assetMediaRepository.deleteAll(localIds);
if (movedIds.isEmpty) {
return candidates;
}
final movedIdLookup = movedIds.toSet();
final resolvedMoveCandidates = selectedMoveCandidates
.where((item) => movedIdLookup.contains(item.candidate.asset.id))
.toList(growable: false);
await _trashSyncRepository.transaction<void>(() async {
await _trashedLocalAssetRepository.trashLocalAssets(resolvedMoveCandidates);
await _trashSyncRepository.deleteResolved(resolvedMoveCandidates.map((item) => item.candidate.asset.checksum!));
});
return candidates.where((candidate) => !movedIdLookup.contains(candidate.asset.id)).toList(growable: false);
}
Future<bool> _hasManageMediaPermission(String logContext) async {
final hasPermission = await _permissionRepository.hasManageMediaPermission();
if (!hasPermission) {
_logger.warning("sync $logContext cannot proceed because MANAGE_MEDIA permission is missing");
}
return hasPermission;
}
Future<bool> _canMoveLocalMediaToTrash() async =>
CurrentPlatform.isAndroid && mode == TrashSyncMode.autoSync && await _hasManageMediaPermission("move to trash");
}

View File

@ -0,0 +1,19 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_trash_sync_is_sync_approved ON trash_sync_entity (is_sync_approved)')
@TableIndex.sql(
'CREATE INDEX IF NOT EXISTS idx_trash_sync_checksum_status ON trash_sync_entity (checksum, is_sync_approved)',
)
class TrashSyncEntity extends Table with DriftDefaultsMixin {
const TrashSyncEntity();
TextColumn get checksum => text()();
BoolColumn get isSyncApproved => boolean().nullable()();
DateTimeColumn get remoteDeletedAt => dateTime()();
@override
Set<Column> get primaryKey => {checksum};
}

View File

@ -0,0 +1,461 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart'
as i1;
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.dart'
as i2;
typedef $$TrashSyncEntityTableCreateCompanionBuilder =
i1.TrashSyncEntityCompanion Function({
required String checksum,
i0.Value<bool?> isSyncApproved,
required DateTime remoteDeletedAt,
});
typedef $$TrashSyncEntityTableUpdateCompanionBuilder =
i1.TrashSyncEntityCompanion Function({
i0.Value<String> checksum,
i0.Value<bool?> isSyncApproved,
i0.Value<DateTime> remoteDeletedAt,
});
class $$TrashSyncEntityTableFilterComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$TrashSyncEntityTable> {
$$TrashSyncEntityTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnFilters<String> get checksum => $composableBuilder(
column: $table.checksum,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<bool> get isSyncApproved => $composableBuilder(
column: $table.isSyncApproved,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<DateTime> get remoteDeletedAt => $composableBuilder(
column: $table.remoteDeletedAt,
builder: (column) => i0.ColumnFilters(column),
);
}
class $$TrashSyncEntityTableOrderingComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$TrashSyncEntityTable> {
$$TrashSyncEntityTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnOrderings<String> get checksum => $composableBuilder(
column: $table.checksum,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<bool> get isSyncApproved => $composableBuilder(
column: $table.isSyncApproved,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<DateTime> get remoteDeletedAt => $composableBuilder(
column: $table.remoteDeletedAt,
builder: (column) => i0.ColumnOrderings(column),
);
}
class $$TrashSyncEntityTableAnnotationComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$TrashSyncEntityTable> {
$$TrashSyncEntityTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.GeneratedColumn<String> get checksum =>
$composableBuilder(column: $table.checksum, builder: (column) => column);
i0.GeneratedColumn<bool> get isSyncApproved => $composableBuilder(
column: $table.isSyncApproved,
builder: (column) => column,
);
i0.GeneratedColumn<DateTime> get remoteDeletedAt => $composableBuilder(
column: $table.remoteDeletedAt,
builder: (column) => column,
);
}
class $$TrashSyncEntityTableTableManager
extends
i0.RootTableManager<
i0.GeneratedDatabase,
i1.$TrashSyncEntityTable,
i1.TrashSyncEntityData,
i1.$$TrashSyncEntityTableFilterComposer,
i1.$$TrashSyncEntityTableOrderingComposer,
i1.$$TrashSyncEntityTableAnnotationComposer,
$$TrashSyncEntityTableCreateCompanionBuilder,
$$TrashSyncEntityTableUpdateCompanionBuilder,
(
i1.TrashSyncEntityData,
i0.BaseReferences<
i0.GeneratedDatabase,
i1.$TrashSyncEntityTable,
i1.TrashSyncEntityData
>,
),
i1.TrashSyncEntityData,
i0.PrefetchHooks Function()
> {
$$TrashSyncEntityTableTableManager(
i0.GeneratedDatabase db,
i1.$TrashSyncEntityTable table,
) : super(
i0.TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
i1.$$TrashSyncEntityTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
i1.$$TrashSyncEntityTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () => i1
.$$TrashSyncEntityTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback:
({
i0.Value<String> checksum = const i0.Value.absent(),
i0.Value<bool?> isSyncApproved = const i0.Value.absent(),
i0.Value<DateTime> remoteDeletedAt = const i0.Value.absent(),
}) => i1.TrashSyncEntityCompanion(
checksum: checksum,
isSyncApproved: isSyncApproved,
remoteDeletedAt: remoteDeletedAt,
),
createCompanionCallback:
({
required String checksum,
i0.Value<bool?> isSyncApproved = const i0.Value.absent(),
required DateTime remoteDeletedAt,
}) => i1.TrashSyncEntityCompanion.insert(
checksum: checksum,
isSyncApproved: isSyncApproved,
remoteDeletedAt: remoteDeletedAt,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
),
);
}
typedef $$TrashSyncEntityTableProcessedTableManager =
i0.ProcessedTableManager<
i0.GeneratedDatabase,
i1.$TrashSyncEntityTable,
i1.TrashSyncEntityData,
i1.$$TrashSyncEntityTableFilterComposer,
i1.$$TrashSyncEntityTableOrderingComposer,
i1.$$TrashSyncEntityTableAnnotationComposer,
$$TrashSyncEntityTableCreateCompanionBuilder,
$$TrashSyncEntityTableUpdateCompanionBuilder,
(
i1.TrashSyncEntityData,
i0.BaseReferences<
i0.GeneratedDatabase,
i1.$TrashSyncEntityTable,
i1.TrashSyncEntityData
>,
),
i1.TrashSyncEntityData,
i0.PrefetchHooks Function()
>;
i0.Index get idxTrashSyncIsSyncApproved => i0.Index(
'idx_trash_sync_is_sync_approved',
'CREATE INDEX IF NOT EXISTS idx_trash_sync_is_sync_approved ON trash_sync_entity (is_sync_approved)',
);
class $TrashSyncEntityTable extends i2.TrashSyncEntity
with i0.TableInfo<$TrashSyncEntityTable, i1.TrashSyncEntityData> {
@override
final i0.GeneratedDatabase attachedDatabase;
final String? _alias;
$TrashSyncEntityTable(this.attachedDatabase, [this._alias]);
static const i0.VerificationMeta _checksumMeta = const i0.VerificationMeta(
'checksum',
);
@override
late final i0.GeneratedColumn<String> checksum = i0.GeneratedColumn<String>(
'checksum',
aliasedName,
false,
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
);
static const i0.VerificationMeta _isSyncApprovedMeta =
const i0.VerificationMeta('isSyncApproved');
@override
late final i0.GeneratedColumn<bool> isSyncApproved = i0.GeneratedColumn<bool>(
'is_sync_approved',
aliasedName,
true,
type: i0.DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'CHECK ("is_sync_approved" IN (0, 1))',
),
);
static const i0.VerificationMeta _remoteDeletedAtMeta =
const i0.VerificationMeta('remoteDeletedAt');
@override
late final i0.GeneratedColumn<DateTime> remoteDeletedAt =
i0.GeneratedColumn<DateTime>(
'remote_deleted_at',
aliasedName,
false,
type: i0.DriftSqlType.dateTime,
requiredDuringInsert: true,
);
@override
List<i0.GeneratedColumn> get $columns => [
checksum,
isSyncApproved,
remoteDeletedAt,
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'trash_sync_entity';
@override
i0.VerificationContext validateIntegrity(
i0.Insertable<i1.TrashSyncEntityData> instance, {
bool isInserting = false,
}) {
final context = i0.VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('checksum')) {
context.handle(
_checksumMeta,
checksum.isAcceptableOrUnknown(data['checksum']!, _checksumMeta),
);
} else if (isInserting) {
context.missing(_checksumMeta);
}
if (data.containsKey('is_sync_approved')) {
context.handle(
_isSyncApprovedMeta,
isSyncApproved.isAcceptableOrUnknown(
data['is_sync_approved']!,
_isSyncApprovedMeta,
),
);
}
if (data.containsKey('remote_deleted_at')) {
context.handle(
_remoteDeletedAtMeta,
remoteDeletedAt.isAcceptableOrUnknown(
data['remote_deleted_at']!,
_remoteDeletedAtMeta,
),
);
} else if (isInserting) {
context.missing(_remoteDeletedAtMeta);
}
return context;
}
@override
Set<i0.GeneratedColumn> get $primaryKey => {checksum};
@override
i1.TrashSyncEntityData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return i1.TrashSyncEntityData(
checksum: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}checksum'],
)!,
isSyncApproved: attachedDatabase.typeMapping.read(
i0.DriftSqlType.bool,
data['${effectivePrefix}is_sync_approved'],
),
remoteDeletedAt: attachedDatabase.typeMapping.read(
i0.DriftSqlType.dateTime,
data['${effectivePrefix}remote_deleted_at'],
)!,
);
}
@override
$TrashSyncEntityTable createAlias(String alias) {
return $TrashSyncEntityTable(attachedDatabase, alias);
}
@override
bool get withoutRowId => true;
@override
bool get isStrict => true;
}
class TrashSyncEntityData extends i0.DataClass
implements i0.Insertable<i1.TrashSyncEntityData> {
final String checksum;
final bool? isSyncApproved;
final DateTime remoteDeletedAt;
const TrashSyncEntityData({
required this.checksum,
this.isSyncApproved,
required this.remoteDeletedAt,
});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
map['checksum'] = i0.Variable<String>(checksum);
if (!nullToAbsent || isSyncApproved != null) {
map['is_sync_approved'] = i0.Variable<bool>(isSyncApproved);
}
map['remote_deleted_at'] = i0.Variable<DateTime>(remoteDeletedAt);
return map;
}
factory TrashSyncEntityData.fromJson(
Map<String, dynamic> json, {
i0.ValueSerializer? serializer,
}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return TrashSyncEntityData(
checksum: serializer.fromJson<String>(json['checksum']),
isSyncApproved: serializer.fromJson<bool?>(json['isSyncApproved']),
remoteDeletedAt: serializer.fromJson<DateTime>(json['remoteDeletedAt']),
);
}
@override
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'checksum': serializer.toJson<String>(checksum),
'isSyncApproved': serializer.toJson<bool?>(isSyncApproved),
'remoteDeletedAt': serializer.toJson<DateTime>(remoteDeletedAt),
};
}
i1.TrashSyncEntityData copyWith({
String? checksum,
i0.Value<bool?> isSyncApproved = const i0.Value.absent(),
DateTime? remoteDeletedAt,
}) => i1.TrashSyncEntityData(
checksum: checksum ?? this.checksum,
isSyncApproved: isSyncApproved.present
? isSyncApproved.value
: this.isSyncApproved,
remoteDeletedAt: remoteDeletedAt ?? this.remoteDeletedAt,
);
TrashSyncEntityData copyWithCompanion(i1.TrashSyncEntityCompanion data) {
return TrashSyncEntityData(
checksum: data.checksum.present ? data.checksum.value : this.checksum,
isSyncApproved: data.isSyncApproved.present
? data.isSyncApproved.value
: this.isSyncApproved,
remoteDeletedAt: data.remoteDeletedAt.present
? data.remoteDeletedAt.value
: this.remoteDeletedAt,
);
}
@override
String toString() {
return (StringBuffer('TrashSyncEntityData(')
..write('checksum: $checksum, ')
..write('isSyncApproved: $isSyncApproved, ')
..write('remoteDeletedAt: $remoteDeletedAt')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(checksum, isSyncApproved, remoteDeletedAt);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is i1.TrashSyncEntityData &&
other.checksum == this.checksum &&
other.isSyncApproved == this.isSyncApproved &&
other.remoteDeletedAt == this.remoteDeletedAt);
}
class TrashSyncEntityCompanion
extends i0.UpdateCompanion<i1.TrashSyncEntityData> {
final i0.Value<String> checksum;
final i0.Value<bool?> isSyncApproved;
final i0.Value<DateTime> remoteDeletedAt;
const TrashSyncEntityCompanion({
this.checksum = const i0.Value.absent(),
this.isSyncApproved = const i0.Value.absent(),
this.remoteDeletedAt = const i0.Value.absent(),
});
TrashSyncEntityCompanion.insert({
required String checksum,
this.isSyncApproved = const i0.Value.absent(),
required DateTime remoteDeletedAt,
}) : checksum = i0.Value(checksum),
remoteDeletedAt = i0.Value(remoteDeletedAt);
static i0.Insertable<i1.TrashSyncEntityData> custom({
i0.Expression<String>? checksum,
i0.Expression<bool>? isSyncApproved,
i0.Expression<DateTime>? remoteDeletedAt,
}) {
return i0.RawValuesInsertable({
if (checksum != null) 'checksum': checksum,
if (isSyncApproved != null) 'is_sync_approved': isSyncApproved,
if (remoteDeletedAt != null) 'remote_deleted_at': remoteDeletedAt,
});
}
i1.TrashSyncEntityCompanion copyWith({
i0.Value<String>? checksum,
i0.Value<bool?>? isSyncApproved,
i0.Value<DateTime>? remoteDeletedAt,
}) {
return i1.TrashSyncEntityCompanion(
checksum: checksum ?? this.checksum,
isSyncApproved: isSyncApproved ?? this.isSyncApproved,
remoteDeletedAt: remoteDeletedAt ?? this.remoteDeletedAt,
);
}
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
if (checksum.present) {
map['checksum'] = i0.Variable<String>(checksum.value);
}
if (isSyncApproved.present) {
map['is_sync_approved'] = i0.Variable<bool>(isSyncApproved.value);
}
if (remoteDeletedAt.present) {
map['remote_deleted_at'] = i0.Variable<DateTime>(remoteDeletedAt.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('TrashSyncEntityCompanion(')
..write('checksum: $checksum, ')
..write('isSyncApproved: $isSyncApproved, ')
..write('remoteDeletedAt: $remoteDeletedAt')
..write(')'))
.toString();
}
}
i0.Index get idxTrashSyncChecksumStatus => i0.Index(
'idx_trash_sync_checksum_status',
'CREATE INDEX IF NOT EXISTS idx_trash_sync_checksum_status ON trash_sync_entity (checksum, is_sync_approved)',
);

View File

@ -1,8 +1,7 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
enum TrashOrigin {
// do not change this order!
@ -13,23 +12,13 @@ enum TrashOrigin {
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)')
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)')
class TrashedLocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
class TrashedLocalAssetEntity extends LocalAssetEntity {
const TrashedLocalAssetEntity();
TextColumn get id => text()();
TextColumn get albumId => text()();
TextColumn get checksum => text().nullable()();
BoolColumn get isFavorite => boolean().withDefault(const Constant(false))();
IntColumn get orientation => integer().withDefault(const Constant(0))();
IntColumn get source => intEnum<TrashOrigin>()();
IntColumn get playbackStyle => intEnum<AssetPlaybackStyle>().withDefault(const Constant(0))();
@override
Set<Column> get primaryKey => {id, albumId};
}

File diff suppressed because it is too large Load Diff

View File

@ -24,6 +24,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
@ -56,6 +57,7 @@ import 'package:logging/logging.dart';
TrashedLocalAssetEntity,
AssetEditEntity,
MetadataEntity,
TrashSyncEntity,
],
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
)
@ -98,7 +100,7 @@ class Drift extends $Drift {
}
@override
int get schemaVersion => 26;
int get schemaVersion => 27;
@override
MigrationStrategy get migration => MigrationStrategy(
@ -276,6 +278,15 @@ class Drift extends $Drift {
from25To26: (m, v26) async {
await m.addColumn(v26.remoteAssetEntity, v26.remoteAssetEntity.uploadedAt);
},
from26To27: (m, v27) async {
await m.create(v27.trashSyncEntity);
await m.createIndex(v27.idxTrashSyncIsSyncApproved);
await m.createIndex(v27.idxTrashSyncChecksumStatus);
await m.addColumn(v27.trashedLocalAssetEntity, v27.trashedLocalAssetEntity.iCloudId);
await m.addColumn(v27.trashedLocalAssetEntity, v27.trashedLocalAssetEntity.adjustmentTime);
await m.addColumn(v27.trashedLocalAssetEntity, v27.trashedLocalAssetEntity.latitude);
await m.addColumn(v27.trashedLocalAssetEntity, v27.trashedLocalAssetEntity.longitude);
},
),
);

View File

@ -45,9 +45,11 @@ import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.da
as i21;
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart'
as i22;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart'
as i23;
import 'package:drift/internal/modular.dart' as i24;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
as i24;
import 'package:drift/internal/modular.dart' as i25;
abstract class $Drift extends i0.GeneratedDatabase {
$Drift(i0.QueryExecutor e) : super(e);
@ -94,9 +96,11 @@ abstract class $Drift extends i0.GeneratedDatabase {
late final i22.$MetadataEntityTable metadataEntity = i22.$MetadataEntityTable(
this,
);
i23.MergedAssetDrift get mergedAssetDrift => i24.ReadDatabaseContainer(
late final i23.$TrashSyncEntityTable trashSyncEntity = i23
.$TrashSyncEntityTable(this);
i24.MergedAssetDrift get mergedAssetDrift => i25.ReadDatabaseContainer(
this,
).accessor<i23.MergedAssetDrift>(i23.MergedAssetDrift.new);
).accessor<i24.MergedAssetDrift>(i24.MergedAssetDrift.new);
@override
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
@ -133,6 +137,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
trashedLocalAssetEntity,
assetEditEntity,
metadataEntity,
trashSyncEntity,
i10.idxPartnerSharedWithId,
i11.idxLatLng,
i11.idxRemoteExifCity,
@ -145,6 +150,8 @@ abstract class $Drift extends i0.GeneratedDatabase {
i20.idxTrashedLocalAssetChecksum,
i20.idxTrashedLocalAssetAlbum,
i21.idxAssetEditAssetId,
i23.idxTrashSyncIsSyncApproved,
i23.idxTrashSyncChecksumStatus,
];
@override
i0.StreamQueryUpdateRules
@ -397,4 +404,6 @@ class $DriftManager {
i21.$$AssetEditEntityTableTableManager(_db, _db.assetEditEntity);
i22.$$MetadataEntityTableTableManager get metadataEntity =>
i22.$$MetadataEntityTableTableManager(_db, _db.metadataEntity);
i23.$$TrashSyncEntityTableTableManager get trashSyncEntity =>
i23.$$TrashSyncEntityTableTableManager(_db, _db.trashSyncEntity);
}

View File

@ -13539,6 +13539,642 @@ i1.GeneratedColumn<String> _column_212(String aliasedName) =>
type: i1.DriftSqlType.string,
$customConstraints: 'NULL',
);
final class Schema27 extends i0.VersionedSchema {
Schema27({required super.database}) : super(version: 27);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAlbumAssetAlbumAsset,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxStackPrimaryAssetId,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
idxRemoteAssetStackId,
idxRemoteAssetOwnerVisibilityDeletedCreated,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
assetEditEntity,
metadata,
trashSyncEntity,
idxPartnerSharedWithId,
idxLatLng,
idxRemoteExifCity,
idxRemoteAlbumAssetAlbumAsset,
idxRemoteAssetCloudId,
idxPersonOwnerId,
idxAssetFacePersonId,
idxAssetFaceAssetId,
idxAssetFaceVisiblePerson,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
idxAssetEditAssetId,
idxTrashSyncIsSyncApproved,
idxTrashSyncChecksumStatus,
];
late final Shape33 userEntity = Shape33(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_110,
_column_111,
_column_112,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape50 remoteAssetEntity = Shape50(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_119,
_column_120,
_column_121,
_column_122,
_column_123,
_column_124,
_column_212,
_column_125,
_column_126,
_column_127,
_column_128,
_column_129,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape35 stackEntity = Shape35(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_130,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape36 localAssetEntity = Shape36(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_131,
_column_120,
_column_132,
_column_133,
_column_134,
_column_135,
_column_136,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape48 remoteAlbumEntity = Shape48(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_138,
_column_114,
_column_115,
_column_139,
_column_140,
_column_141,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape38 localAlbumEntity = Shape38(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_115,
_column_142,
_column_143,
_column_144,
_column_145,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape39 localAlbumAssetEntity = Shape39(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_146, _column_147, _column_145],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
'idx_local_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxLocalAssetCloudId = i1.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
final i1.Index idxStackPrimaryAssetId = i1.Index(
'idx_stack_primary_asset_id',
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
final i1.Index idxRemoteAssetStackId = i1.Index(
'idx_remote_asset_stack_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
);
final i1.Index idxRemoteAssetOwnerVisibilityDeletedCreated = i1.Index(
'idx_remote_asset_owner_visibility_deleted_created',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)',
);
late final Shape40 authUserEntity = Shape40(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_148,
_column_110,
_column_111,
_column_149,
_column_150,
_column_151,
_column_152,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_153, _column_154, _column_155],
attachedDatabase: database,
),
alias: null,
);
late final Shape41 partnerEntity = Shape41(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_156, _column_157, _column_158],
attachedDatabase: database,
),
alias: null,
);
late final Shape42 remoteExifEntity = Shape42(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_160,
_column_161,
_column_162,
_column_163,
_column_164,
_column_117,
_column_116,
_column_165,
_column_166,
_column_167,
_column_168,
_column_135,
_column_136,
_column_169,
_column_170,
_column_171,
_column_172,
_column_173,
_column_174,
_column_175,
_column_176,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_159, _column_177],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_177, _column_153, _column_178],
attachedDatabase: database,
),
alias: null,
);
late final Shape43 remoteAssetCloudIdEntity = Shape43(
source: i0.VersionedTable(
entityName: 'remote_asset_cloud_id_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_179,
_column_180,
_column_134,
_column_135,
_column_136,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape44 memoryEntity = Shape44(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_124,
_column_121,
_column_113,
_column_181,
_column_182,
_column_183,
_column_184,
_column_185,
_column_186,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_159, _column_187],
attachedDatabase: database,
),
alias: null,
);
late final Shape45 personEntity = Shape45(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_108,
_column_188,
_column_189,
_column_190,
_column_191,
_column_192,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape46 assetFaceEntity = Shape46(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_193,
_column_194,
_column_195,
_column_196,
_column_197,
_column_198,
_column_199,
_column_200,
_column_201,
_column_124,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_202, _column_203, _column_204],
attachedDatabase: database,
),
alias: null,
);
late final Shape51 trashedLocalAssetEntity = Shape51(
source: i0.VersionedTable(
entityName: 'trashed_local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id, album_id)'],
columns: [
_column_107,
_column_131,
_column_120,
_column_132,
_column_133,
_column_134,
_column_135,
_column_136,
_column_137,
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_205,
_column_206,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape32 assetEditEntity = Shape32(
source: i0.VersionedTable(
entityName: 'asset_edit_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_207,
_column_208,
_column_209,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape49 metadata = Shape49(
source: i0.VersionedTable(
entityName: 'metadata',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY("key")'],
columns: [_column_210, _column_211, _column_115],
attachedDatabase: database,
),
alias: null,
);
late final Shape52 trashSyncEntity = Shape52(
source: i0.VersionedTable(
entityName: 'trash_sync_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(checksum)'],
columns: [_column_119, _column_213, _column_214],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxPartnerSharedWithId = i1.Index(
'idx_partner_shared_with_id',
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
final i1.Index idxRemoteExifCity = i1.Index(
'idx_remote_exif_city',
'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL',
);
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
'idx_remote_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxRemoteAssetCloudId = i1.Index(
'idx_remote_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
);
final i1.Index idxPersonOwnerId = i1.Index(
'idx_person_owner_id',
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
);
final i1.Index idxAssetFacePersonId = i1.Index(
'idx_asset_face_person_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
);
final i1.Index idxAssetFaceAssetId = i1.Index(
'idx_asset_face_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
);
final i1.Index idxAssetFaceVisiblePerson = i1.Index(
'idx_asset_face_visible_person',
'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL',
);
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
'idx_trashed_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
);
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
'idx_trashed_local_asset_album',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
);
final i1.Index idxAssetEditAssetId = i1.Index(
'idx_asset_edit_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
);
final i1.Index idxTrashSyncIsSyncApproved = i1.Index(
'idx_trash_sync_is_sync_approved',
'CREATE INDEX IF NOT EXISTS idx_trash_sync_is_sync_approved ON trash_sync_entity (is_sync_approved)',
);
final i1.Index idxTrashSyncChecksumStatus = i1.Index(
'idx_trash_sync_checksum_status',
'CREATE INDEX IF NOT EXISTS idx_trash_sync_checksum_status ON trash_sync_entity (checksum, is_sync_approved)',
);
}
class Shape51 extends i0.VersionedTable {
Shape51({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get checksum =>
columnsByName['checksum']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get isFavorite =>
columnsByName['is_favorite']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get orientation =>
columnsByName['orientation']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get iCloudId =>
columnsByName['i_cloud_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get adjustmentTime =>
columnsByName['adjustment_time']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<double> get latitude =>
columnsByName['latitude']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<double> get longitude =>
columnsByName['longitude']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<int> get playbackStyle =>
columnsByName['playback_style']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get type =>
columnsByName['type']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get width =>
columnsByName['width']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get height =>
columnsByName['height']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get durationMs =>
columnsByName['duration_ms']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get albumId =>
columnsByName['album_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get source =>
columnsByName['source']! as i1.GeneratedColumn<int>;
}
class Shape52 extends i0.VersionedTable {
Shape52({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get checksum =>
columnsByName['checksum']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get isSyncApproved =>
columnsByName['is_sync_approved']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get remoteDeletedAt =>
columnsByName['remote_deleted_at']! as i1.GeneratedColumn<String>;
}
i1.GeneratedColumn<int> _column_213(String aliasedName) =>
i1.GeneratedColumn<int>(
'is_sync_approved',
aliasedName,
true,
type: i1.DriftSqlType.int,
$customConstraints: 'NULL CHECK (is_sync_approved IN (0, 1))',
);
i1.GeneratedColumn<String> _column_214(String aliasedName) =>
i1.GeneratedColumn<String>(
'remote_deleted_at',
aliasedName,
false,
type: i1.DriftSqlType.string,
$customConstraints: 'NOT NULL',
);
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@ -13565,6 +14201,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema24 schema) from23To24,
required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25,
required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26,
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@ -13693,6 +14330,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from25To26(migrator, schema);
return 26;
case 26:
final schema = Schema27(database: database);
final migrator = i1.Migrator(database, schema);
await from26To27(migrator, schema);
return 27;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@ -13725,6 +14367,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema24 schema) from23To24,
required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25,
required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26,
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@ -13752,5 +14395,6 @@ i1.OnUpgrade stepByStep({
from23To24: from23To24,
from24To25: from24To25,
from25To26: from25To26,
from26To27: from26To27,
),
);

View File

@ -1,8 +1,6 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@ -109,43 +107,6 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
return query.map((localAlbum) => localAlbum.toDto()).get();
}
Future<Map<String, List<LocalAsset>>> getAssetsFromBackupAlbums(Iterable<String> remoteIds) async {
if (remoteIds.isEmpty) {
return {};
}
final result = <String, List<LocalAsset>>{};
for (final slice in remoteIds.toSet().slices(kDriftMaxChunk)) {
final rows =
await (_db.select(_db.localAlbumAssetEntity).join([
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
innerJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum),
useColumns: false,
),
])..where(
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
_db.remoteAssetEntity.id.isIn(slice),
))
.get();
for (final row in rows) {
final albumId = row.readTable(_db.localAlbumAssetEntity).albumId;
final asset = row.readTable(_db.localAssetEntity).toDto();
(result[albumId] ??= <LocalAsset>[]).add(asset);
}
}
return result;
}
Future<RemovalCandidatesResult> getRemovalCandidates(
String userId,
DateTime cutoffDate, {

View File

@ -84,6 +84,14 @@ class StorageRepository {
return entity;
}
Future<String?> getMediaUrlForAsset(LocalAsset asset) async {
final entity = await getAssetEntityForAsset(asset);
if (entity == null) {
return null;
}
return entity.getMediaUrl();
}
Future<bool> isAssetAvailableLocally(String assetId) async {
try {
final entity = await AssetEntity.fromId(assetId);

View File

@ -4,10 +4,12 @@ import 'package:drift/drift.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
@ -346,6 +348,12 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
joinLocal: true,
);
TimelineQuery toTrashSyncReview(GroupAssetsBy groupBy) => (
bucketSource: () => _watchTrashSyncBucket(groupBy: groupBy),
assetSource: (offset, count) => _getToTrashSyncBucketAssets(offset: offset, count: count),
origin: TimelineOrigin.syncTrash,
);
TimelineQuery archived(String userId, GroupAssetsBy groupBy) => _remoteQueryBuilder(
filter: (row) =>
row.deletedAt.isNull() & row.ownerId.equals(userId) & row.visibility.equalsValue(AssetVisibility.archive),
@ -678,6 +686,87 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
return query.map((row) => row.toDto()).get();
}
}
Stream<List<Bucket>> _watchTrashSyncBucket({GroupAssetsBy groupBy = GroupAssetsBy.day}) {
if (groupBy == GroupAssetsBy.none) {
// TODO: implement GroupAssetBy for place
throw UnsupportedError("GroupAssetsBy.none is not supported for watchPlaceBucket");
}
return _watchTrashSyncLocalAssets().map((assets) {
final bucketCounts = <DateTime, int>{};
for (final asset in assets) {
final localTime = asset.createdAt.toLocal();
final bucketDate = switch (groupBy) {
GroupAssetsBy.day || GroupAssetsBy.auto => DateTime(localTime.year, localTime.month, localTime.day),
GroupAssetsBy.month => DateTime(localTime.year, localTime.month),
GroupAssetsBy.none => throw ArgumentError("GroupAssetsBy.none is not supported for date formatting"),
};
bucketCounts[bucketDate] = (bucketCounts[bucketDate] ?? 0) + 1;
}
return bucketCounts.entries.map((entry) => TimeBucket(date: entry.key, assetCount: entry.value)).toList();
});
}
Future<List<BaseAsset>> _getToTrashSyncBucketAssets({required int offset, required int count}) async {
return _getTrashSyncLocalAssets(offset: offset, count: count);
}
Stream<List<LocalAsset>> _watchTrashSyncLocalAssets() {
return _trashSyncLocalAssetsQuery().watch().map((rows) => rows.map((row) => row.toDto()).toList(growable: false));
}
Future<List<LocalAsset>> _getTrashSyncLocalAssets({required int offset, required int count}) {
return _trashSyncLocalAssetsQuery(
offset: offset,
count: count,
).get().then((rows) => rows.map((row) => row.toDto()).toList(growable: false));
}
SimpleSelectStatement<$LocalAssetEntityTable, LocalAssetEntityData> _trashSyncLocalAssetsQuery({
int? offset,
int? count,
}) {
final pendingTrashChecksums = _db.trashSyncEntity.selectOnly()
..addColumns([_db.trashSyncEntity.checksum])
..where(_db.trashSyncEntity.isSyncApproved.isNull());
final selectedAlbumAssets =
_db.localAlbumAssetEntity.selectOnly().join([
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
])
..addColumns([_db.localAlbumAssetEntity.assetId])
..where(
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id) &
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected),
);
final representativeId = _db.localAssetEntity.id.min();
final representativeIds = _db.localAssetEntity.selectOnly()
..addColumns([representativeId])
..where(
_db.localAssetEntity.checksum.isNotNull() &
_db.localAssetEntity.checksum.isInQuery(pendingTrashChecksums) &
existsQuery(selectedAlbumAssets),
)
..groupBy([_db.localAssetEntity.checksum]);
final query = _db.localAssetEntity.select()
..where((row) => row.id.isInQuery(representativeIds))
..orderBy([(row) => OrderingTerm.desc(row.createdAt), (row) => OrderingTerm.asc(row.id)]);
if (count != null) {
query.limit(count, offset: offset);
}
return query;
}
}
List<Bucket> _generateBuckets(int count) {

View File

@ -0,0 +1,321 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/remote_deleted_local_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
typedef RemoteTrashMoveCandidate = ({String albumId, RemoteDeletedLocalAsset candidate});
class DriftTrashSyncRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftTrashSyncRepository(this._db) : super(_db);
Future<List<RemoteDeletedLocalAsset>> getRemoteTrashCandidates(
Map<String, DateTime> remoteDeletedAtByRemoteId,
) async {
if (remoteDeletedAtByRemoteId.isEmpty) {
return [];
}
final result = <RemoteDeletedLocalAsset>[];
for (final slice in remoteDeletedAtByRemoteId.keys.toSet().slices(kDriftMaxChunk)) {
final rows =
await (_db.select(_db.localAssetEntity).join([
innerJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum),
useColumns: false,
),
leftOuterJoin(
_db.trashSyncEntity,
_db.localAssetEntity.checksum.equalsExp(_db.trashSyncEntity.checksum) &
_db.trashSyncEntity.isSyncApproved.isNotNull(),
useColumns: false,
),
])
..addColumns([_db.remoteAssetEntity.id])
..where(
//todo should we filter hidden assets?
//_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
_db.remoteAssetEntity.id.isIn(slice) & _db.trashSyncEntity.checksum.isNull(),
))
.get();
for (final row in rows) {
final assetData = row.readTable(_db.localAssetEntity);
final remoteId = row.read(_db.remoteAssetEntity.id)!;
result.add(
RemoteDeletedLocalAsset(
asset: assetData.toDto(remoteId: remoteId),
remoteDeletedAt: remoteDeletedAtByRemoteId[remoteId]!,
),
);
}
}
return result;
}
Future<List<RemoteTrashMoveCandidate>> getSelectedBackupRemoteTrashMoveCandidates(
Iterable<RemoteDeletedLocalAsset> candidates,
) async {
if (candidates.isEmpty) {
return [];
}
final result = <RemoteTrashMoveCandidate>[];
final candidatesById = {for (final candidate in candidates) candidate.asset.id: candidate};
for (final slice in candidatesById.keys.slices(kDriftMaxChunk)) {
final rows =
await (_db.select(_db.localAlbumAssetEntity).join([
innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id)),
])..where(
_db.localAlbumAssetEntity.assetId.isIn(slice) &
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected),
))
.get();
for (final row in rows) {
final albumAsset = row.readTable(_db.localAlbumAssetEntity);
result.add((albumId: albumAsset.albumId, candidate: candidatesById[albumAsset.assetId]!));
}
}
return result;
}
Future<List<RemoteTrashMoveCandidate>> getTrashSyncMoveCandidates(Iterable<String> checksums) async {
if (checksums.isEmpty) {
return [];
}
final result = <RemoteTrashMoveCandidate>[];
for (final slice in checksums.slices(kDriftMaxChunk)) {
final rows =
await (_db.select(_db.localAlbumAssetEntity).join([
innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id)),
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
innerJoin(
_db.trashSyncEntity,
_db.localAssetEntity.checksum.equalsExp(_db.trashSyncEntity.checksum),
useColumns: false,
),
])
..addColumns([_db.trashSyncEntity.remoteDeletedAt])
..where(
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
_db.localAssetEntity.checksum.isIn(slice) &
_db.trashSyncEntity.isSyncApproved.isNull(),
))
.get();
for (final row in rows) {
final albumId = row.readTable(_db.localAlbumAssetEntity).albumId;
final assetData = row.readTable(_db.localAssetEntity);
final remoteDeletedAt = row.read(_db.trashSyncEntity.remoteDeletedAt)!;
result.add((
albumId: albumId,
candidate: RemoteDeletedLocalAsset(asset: assetData.toDto(), remoteDeletedAt: remoteDeletedAt),
));
}
}
return result;
}
Future<void> upsertReviewCandidates(Iterable<RemoteDeletedLocalAsset> itemsToReview) async {
if (itemsToReview.isEmpty) {
return Future.value();
}
final existingEntities = <TrashSyncEntityData>[];
final checksums = itemsToReview.map((e) => e.asset.checksum).nonNulls;
for (final slice in checksums.slices(kDriftMaxChunk)) {
final sliceResult = await (_db.trashSyncEntity.select()..where((tbl) => tbl.checksum.isIn(slice))).get();
existingEntities.addAll(sliceResult);
}
final existingMap = {for (var e in existingEntities) e.checksum: e};
return _db.batch((batch) {
for (var item in itemsToReview) {
final checksum = item.asset.checksum!;
final existing = existingMap[checksum];
if (existing == null ||
(existing.isSyncApproved == null && item.remoteDeletedAt.isAfter(existing.remoteDeletedAt))) {
batch.insert(
_db.trashSyncEntity,
TrashSyncEntityCompanion.insert(checksum: checksum, remoteDeletedAt: item.remoteDeletedAt),
onConflict: DoUpdate(
(_) => TrashSyncEntityCompanion.custom(
remoteDeletedAt: Variable(item.remoteDeletedAt),
isSyncApproved: const Variable(null),
),
),
);
}
}
});
}
Future<void> updateApproves(Iterable<String> checksums, bool isSyncApproved) {
if (checksums.isEmpty) {
return Future.value();
}
return _db.batch((batch) {
for (final slice in checksums.slices(kDriftMaxChunk)) {
batch.update(
_db.trashSyncEntity,
TrashSyncEntityCompanion(isSyncApproved: Value(isSyncApproved)),
where: (tbl) => tbl.checksum.isIn(slice),
);
}
});
}
Future<int> deleteOutdated(Iterable<String> remoteIds) async {
var deletedMatched = 0;
for (final slice in remoteIds.toSet().slices(kDriftMaxChunk)) {
final remoteAliveSelect = _db.selectOnly(_db.remoteAssetEntity)
..addColumns([_db.remoteAssetEntity.checksum])
..where(_db.remoteAssetEntity.id.isIn(slice) & _db.remoteAssetEntity.deletedAt.isNull());
final query = _db.delete(_db.trashSyncEntity)..where((row) => row.checksum.isInQuery(remoteAliveSelect));
deletedMatched += await query.go();
}
return deletedMatched;
}
Future<int> deleteResolved(Iterable<String> checksums) async {
final checksumSet = checksums.toSet();
if (checksumSet.isEmpty) {
return Future.value(0);
}
var deletedMatched = 0;
for (final slice in checksumSet.slices(kDriftMaxChunk)) {
final query = _db.delete(_db.trashSyncEntity)
..where((row) => row.checksum.isIn(slice) & row.isSyncApproved.isNotValue(true));
deletedMatched += await query.go();
}
return deletedMatched;
}
Future<int> cleanupLocalTrashSync() async {
final orphanedReviews = await _deleteOrphanedReviews();
final staleReviews = await _deleteStaleReviewsThrottled();
return orphanedReviews + staleReviews;
}
Future<int> _deleteOrphanedReviews() {
final localAssetChecksums = _db.selectOnly(_db.localAssetEntity)
..addColumns([_db.localAssetEntity.checksum])
..where(_db.localAssetEntity.checksum.isNotNull());
final query = _db.delete(_db.trashSyncEntity)
..where((row) => row.checksum.isNotInQuery(localAssetChecksums) & row.isSyncApproved.isNotValue(true));
return query.go();
}
Future<int> _deleteStaleReviewsThrottled({Duration minInterval = const Duration(hours: 8)}) async {
final lastRunMillis = await _getLastCleanupTimeMillis();
final nowMillis = DateTime.now().millisecondsSinceEpoch;
if (lastRunMillis != null && nowMillis - lastRunMillis < minInterval.inMilliseconds) {
return 0;
}
final result = await _cleanupOutdatedEntries();
await _setLastCleanupTimeMillis(nowMillis);
return result;
}
Future<int> _cleanupOutdatedEntries() async {
final remoteAliveSelect = _db.selectOnly(_db.remoteAssetEntity)
..addColumns([_db.remoteAssetEntity.checksum])
..where(_db.remoteAssetEntity.deletedAt.isNull());
final query = _db.delete(_db.trashSyncEntity)..where((row) => row.checksum.isInQuery(remoteAliveSelect));
final deletedMatched = await query.go();
final localTrashedChecksums = _db.selectOnly(_db.trashedLocalAssetEntity)
..addColumns([_db.trashedLocalAssetEntity.checksum])
..where(_db.trashedLocalAssetEntity.checksum.isNotNull());
final orphanQuery = _db.delete(_db.trashSyncEntity)
..where((row) => row.isSyncApproved.equals(true) & row.checksum.isNotInQuery(localTrashedChecksums));
final deletedOrphans = await orphanQuery.go();
return deletedMatched + deletedOrphans;
}
Stream<int> watchPendingApprovalAssetCount() {
final countExpr = _db.trashSyncEntity.checksum.count(distinct: true);
final q = _db.selectOnly(_db.trashSyncEntity)
..addColumns([countExpr])
..where(_db.trashSyncEntity.isSyncApproved.isNull() & _hasEligibleLocalAssetForPendingReview());
return q.watchSingle().map((row) => row.read(countExpr) ?? 0).distinct();
}
Stream<bool> watchIsAssetApprovalPending(String checksum) {
final query = _db.selectOnly(_db.trashSyncEntity)
..addColumns([_db.trashSyncEntity.checksum])
..where(
_db.trashSyncEntity.checksum.equals(checksum) &
_db.trashSyncEntity.isSyncApproved.isNull() &
_hasEligibleLocalAssetForPendingReview(),
)
..limit(1);
return query.watchSingleOrNull().map((row) => row != null).distinct();
}
Expression<bool> _hasEligibleLocalAssetForPendingReview() {
final selectedAlbumAssets =
_db.localAlbumAssetEntity.selectOnly().join([
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
])
..addColumns([_db.localAlbumAssetEntity.assetId])
..where(
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id) &
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected),
);
final eligibleLocalAssets = _db.localAssetEntity.selectOnly()
..addColumns([_db.localAssetEntity.id])
..where(_db.localAssetEntity.checksum.equalsExp(_db.trashSyncEntity.checksum) & existsQuery(selectedAlbumAssets))
..limit(1);
return existsQuery(eligibleLocalAssets);
}
Future<int?> _getLastCleanupTimeMillis() async {
final entity = await _db.managers.storeEntity
.filter((entity) => entity.id.equals(StoreKey.trashSyncLastCleanup.id))
.getSingleOrNull();
return entity?.intValue;
}
Future<void> _setLastCleanupTimeMillis(int millis) async {
await _db.storeEntity.insertOnConflictUpdate(
StoreEntityCompanion(id: Value(StoreKey.trashSyncLastCleanup.id), intValue: Value(millis)),
);
}
}

View File

@ -3,11 +3,11 @@ import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
typedef TrashedAsset = ({String albumId, LocalAsset asset});
@ -20,7 +20,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
if (hashes.isEmpty) {
return Future.value();
}
return _db.batch((batch) async {
return _db.batch((batch) {
for (final entry in hashes.entries) {
batch.update(
_db.trashedLocalAssetEntity,
@ -104,9 +104,11 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
_db.trashedLocalAssetEntity,
)..addColumns([_db.trashedLocalAssetEntity.id])).map((r) => r.read(_db.trashedLocalAssetEntity.id)!).get();
final idToDelete = existingIds.where((id) => !assetIds.contains(id));
for (final slice in idToDelete.slices(kDriftMaxChunk)) {
await (_db.delete(_db.trashedLocalAssetEntity)..where((t) => t.id.isIn(slice))).go();
}
await _db.batch((batch) {
for (final slice in idToDelete.slices(kDriftMaxChunk)) {
batch.deleteWhere(_db.trashedLocalAssetEntity, (t) => t.id.isIn(slice));
}
});
}
});
}
@ -125,45 +127,44 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
.map((row) => row.read<int>(_db.trashedLocalAssetEntity.id.count()) ?? 0);
}
Future<void> trashLocalAsset(Map<String, List<LocalAsset>> assetsByAlbums) async {
if (assetsByAlbums.isEmpty) {
Future<void> trashLocalAssets(Iterable<RemoteTrashMoveCandidate> candidates) async {
if (candidates.isEmpty) {
return Future.value();
}
final companions = <TrashedLocalAssetEntityCompanion>[];
final idToDelete = <String>{};
for (final entry in assetsByAlbums.entries) {
for (final asset in entry.value) {
idToDelete.add(asset.id);
companions.add(
TrashedLocalAssetEntityCompanion(
id: Value(asset.id),
name: Value(asset.name),
albumId: Value(entry.key),
checksum: Value(asset.checksum),
type: Value(asset.type),
width: Value(asset.width),
height: Value(asset.height),
durationMs: Value(asset.durationMs),
isFavorite: Value(asset.isFavorite),
orientation: Value(asset.orientation),
playbackStyle: Value(asset.playbackStyle),
createdAt: Value(asset.createdAt),
updatedAt: Value(asset.updatedAt),
source: const Value(TrashOrigin.remoteSync),
),
);
}
for (final candidate in candidates) {
final asset = candidate.candidate.asset;
idToDelete.add(asset.id);
companions.add(
TrashedLocalAssetEntityCompanion(
id: Value(asset.id),
name: Value(asset.name),
albumId: Value(candidate.albumId),
checksum: Value(asset.checksum),
type: Value(asset.type),
width: Value(asset.width),
height: Value(asset.height),
durationMs: Value(asset.durationMs),
isFavorite: Value(asset.isFavorite),
orientation: Value(asset.orientation),
playbackStyle: Value(asset.playbackStyle),
createdAt: Value(asset.createdAt),
updatedAt: Value(asset.updatedAt),
source: const Value(TrashOrigin.remoteSync),
),
);
}
await _db.transaction(() async {
// Keep this transaction-free; callers commit it together with trashSyncEntity updates.
await _db.batch((batch) {
for (final companion in companions) {
await _db.into(_db.trashedLocalAssetEntity).insertOnConflictUpdate(companion);
batch.insert(_db.trashedLocalAssetEntity, companion, onConflict: DoUpdate((_) => companion));
}
for (final slice in idToDelete.slices(kDriftMaxChunk)) {
await (_db.delete(_db.localAssetEntity)..where((t) => t.id.isIn(slice))).go();
batch.deleteWhere(_db.localAssetEntity, (t) => t.id.isIn(slice));
}
});
}
@ -202,12 +203,14 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
});
await _db.transaction(() async {
for (final companion in companions) {
await _db.into(_db.localAssetEntity).insertOnConflictUpdate(companion);
}
for (final slice in idList.slices(kDriftMaxChunk)) {
await (_db.delete(_db.trashedLocalAssetEntity)..where((t) => t.id.isIn(slice))).go();
}
await _db.batch((batch) {
for (final companion in companions) {
batch.insert(_db.localAssetEntity, companion, onConflict: DoUpdate((_) => companion));
}
for (final slice in idList.slices(kDriftMaxChunk)) {
batch.deleteWhere(_db.trashedLocalAssetEntity, (t) => t.id.isIn(slice));
}
});
});
}
@ -255,41 +258,17 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
});
await _db.transaction(() async {
for (final companion in companions) {
await _db.into(_db.trashedLocalAssetEntity).insertOnConflictUpdate(companion);
}
for (final slice in idList.slices(kDriftMaxChunk)) {
await (_db.delete(_db.localAssetEntity)..where((t) => t.id.isIn(slice))).go();
}
await _db.batch((batch) {
for (final companion in companions) {
batch.insert(_db.trashedLocalAssetEntity, companion, onConflict: DoUpdate((_) => companion));
}
for (final slice in idList.slices(kDriftMaxChunk)) {
batch.deleteWhere(_db.localAssetEntity, (t) => t.id.isIn(slice));
}
});
});
}
Future<Map<String, List<LocalAsset>>> getToTrash() async {
final result = <String, List<LocalAsset>>{};
final rows =
await (_db.select(_db.localAlbumAssetEntity).join([
innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id)),
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
leftOuterJoin(
_db.remoteAssetEntity,
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum),
),
])..where(
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
_db.remoteAssetEntity.deletedAt.isNotNull(),
))
.get();
for (final row in rows) {
final albumId = row.readTable(_db.localAlbumAssetEntity).albumId;
final asset = row.readTable(_db.localAssetEntity).toDto();
(result[albumId] ??= <LocalAsset>[]).add(asset);
}
return result;
}
//attempt to reuse existing checksums
Future<Map<String, String>> _getCachedChecksums(Set<String> assetIds) async {
final localChecksumById = <String, String>{};

View File

@ -0,0 +1,60 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/trash_sync_bottom_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@RoutePage()
class DriftTrashSyncReviewPage extends ConsumerWidget {
const DriftTrashSyncReviewPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) => ProviderScope(
overrides: [
timelineServiceProvider.overrideWith((ref) {
final user = ref.watch(currentUserProvider);
if (user == null) {
throw Exception('User must be logged in to access trash');
}
final timelineService = ref.watch(timelineFactoryProvider).toTrashSyncReview();
ref.onDispose(timelineService.dispose);
return timelineService;
}),
],
child: Timeline(
appBar: SliverAppBar(
title: Text('asset_out_of_sync_title'.tr()),
floating: true,
snap: true,
pinned: true,
centerTitle: true,
elevation: 0,
),
topSliverWidgetHeight: 24,
topSliverWidget: SliverPadding(
padding: const EdgeInsets.all(16.0),
sliver: SliverToBoxAdapter(
child: SizedBox(
height: 72.0,
child: Consumer(
builder: (context, ref, _) {
final outOfSyncCount = ref.watch(outOfSyncAssetsCountProvider).value ?? 0;
return outOfSyncCount > 0
? const Text('asset_out_of_sync_trash_subtitle').tr()
: Center(
child: Text('asset_out_of_sync_trash_subtitle_result', style: context.textTheme.bodyLarge).tr(),
);
},
),
),
),
),
bottomSheet: const TrashSyncBottomBar(),
),
);
}

View File

@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
void showKeepResultToast(BuildContext context, ActionResult result) {
if (!context.mounted) {
return;
}
final message = result.success
? 'assets_denied_to_moved_to_trash_count'.t(args: {'count': '${result.count}'})
: 'scaffold_body_error_occurred'.t();
ImmichToast.show(
context: context,
msg: message,
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
/// This deny move to trash action has the following behavior:
/// - Deny moving to the local trash those assets that are in the remote trash.
///
/// This action is used when the asset is selected in multi-selection mode in the trash page
class KeepOnDeviceActionButton extends ConsumerWidget {
final ActionSource source;
final void Function(ActionResult result) onResult;
const KeepOnDeviceActionButton({super.key, required this.source, required this.onResult});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
ref.read(assetViewerProvider.notifier).setControls(false);
final actionNotifier = ref.read(actionProvider.notifier);
final multiSelectNotifier = ref.read(multiSelectProvider.notifier);
final result = await actionNotifier.resolveRemoteTrash(source, isSyncApproved: false);
onResult.call(result);
multiSelectNotifier.reset();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
const iconData = Icons.cloud_off_outlined;
return source == ActionSource.viewer
? BaseActionButton(
maxWidth: 110.0,
iconData: iconData,
label: 'keep'.t(),
onPressed: () => _onTap(context, ref),
)
: TextButton.icon(
icon: const Icon(iconData),
label: Text('keep_on_device'.t(), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
onPressed: () => _onTap(context, ref),
);
}
}

View File

@ -0,0 +1,110 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
void showTrashResultToast(BuildContext context, ActionResult result) {
if (!context.mounted) {
return;
}
final message = result.success
? 'assets_moved_to_trash_count'.t(args: {'count': '${result.count}'})
: 'errors.something_went_wrong'.t();
ImmichToast.show(
context: context,
msg: message,
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.info : ToastType.error,
);
}
/// This move to trash action has the following behavior:
/// - Allows moving to the local trash those assets that are in the remote trash.
///
/// This action is used when the asset is selected in multi-selection mode in the review out-of-sync changes
class MoveToTrashActionButton extends ConsumerWidget {
final ActionSource source;
final void Function(ActionResult result) onResult;
const MoveToTrashActionButton({super.key, required this.source, required this.onResult});
Future<bool> _shouldShowConfirmationDialog(WidgetRef ref) async {
if (CurrentPlatform.isIOS) {
return Future.value(false);
}
return ref.read(permissionRepositoryProvider).hasManageMediaPermission();
}
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
if (await _shouldShowConfirmationDialog(ref)) {
if (!context.mounted) {
return;
}
final assetViewerNotifier = ref.read(assetViewerProvider.notifier);
assetViewerNotifier.setControls(false);
final selectedCount = source == ActionSource.viewer ? 1 : ref.read(multiSelectProvider).selectedAssets.length;
final confirmed = await showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text('asset_out_of_sync_trash_confirmation_title'.tr()),
content: Text('asset_out_of_sync_trash_confirmation_text'.t(args: {'count': '$selectedCount'})),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(false), child: Text('cancel'.tr())),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.error),
child: Text('control_bottom_app_bar_trash_from_immich'.tr()),
),
],
);
},
);
if (confirmed != true) {
assetViewerNotifier.setControls(true);
return;
}
}
final actionNotifier = ref.read(actionProvider.notifier);
final multiSelectNotifier = ref.read(multiSelectProvider.notifier);
final result = await actionNotifier.resolveRemoteTrash(source, isSyncApproved: true);
onResult.call(result);
multiSelectNotifier.reset();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
const iconData = Icons.delete_forever_outlined;
return (source == ActionSource.viewer)
? BaseActionButton(
maxWidth: 100.0,
iconData: iconData,
label: 'delete'.tr(),
onPressed: () => _onTap(context, ref),
)
: TextButton.icon(
icon: Icon(iconData, color: Colors.red[400]),
label: Text(
'control_bottom_app_bar_trash_from_immich'.tr(),
style: TextStyle(fontSize: 14, color: Colors.red[400], fontWeight: FontWeight.bold),
),
onPressed: () => _onTap(context, ref),
);
}
}

View File

@ -77,8 +77,9 @@ class _AssetPageState extends ConsumerState<AssetPage> {
@override
void didUpdateWidget(AssetPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.index != widget.index) {
_asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index);
final asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index);
if (oldWidget.index != widget.index || asset?.heroTag != _asset?.heroTag) {
_asset = asset;
}
}

View File

@ -86,6 +86,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
late int _currentPage = widget.initialIndex;
late int _totalAssets = ref.read(timelineServiceProvider).totalAssets;
bool _isPopping = false;
StreamSubscription? _reloadSubscription;
KeepAliveLink? _stackChildrenKeepAlive;
@ -228,6 +229,10 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
final totalAssets = timelineService.totalAssets;
if (totalAssets == 0) {
if (_isPopping) {
return;
}
_isPopping = true;
context.maybePop();
return;
}

View File

@ -9,6 +9,8 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/keep_on_device_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
@ -39,29 +41,49 @@ class ViewerBottomBar extends ConsumerWidget {
final serverInfo = ref.watch(serverInfoProvider);
final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash;
final timelineOrigin = ref.read(timelineServiceProvider).origin;
final isSyncTrashTimeline = timelineOrigin == TimelineOrigin.syncTrash;
final originalTheme = context.themeData;
final actions = <Widget>[
if (isInTrash && isOwner && asset.hasRemote)
const RestoreActionButton(source: ActionSource.viewer)
else
const ShareActionButton(source: ActionSource.viewer),
if (isSyncTrashTimeline) ...[
KeepOnDeviceActionButton(
source: ActionSource.viewer,
onResult: (result) {
showKeepResultToast(context, result);
_updateView(ref);
},
),
MoveToTrashActionButton(
source: ActionSource.viewer,
onResult: (result) {
showTrashResultToast(context, result);
_updateView(ref);
},
),
] else ...[
if (isInTrash && isOwner && asset.hasRemote)
const RestoreActionButton(source: ActionSource.viewer)
else
const ShareActionButton(source: ActionSource.viewer),
if (!isInLockedView) ...[
if (!isInTrash) ...[
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
// edit sync was added in 2.6.0
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
const EditImageActionButton(),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
],
if (isOwner) ...[
if (asset.isLocalOnly)
const DeleteLocalActionButton(source: ActionSource.viewer)
else if (asset.isTrashed)
const DeletePermanentActionButton(source: ActionSource.viewer, useShortLabel: true)
else
const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
if (!isInLockedView) ...[
if (!isInTrash) ...[
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
// edit sync was added in 2.6.0
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
const EditImageActionButton(),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
],
if (isOwner) ...[
if (asset.isLocalOnly)
const DeleteLocalActionButton(source: ActionSource.viewer)
else if (asset.isTrashed)
const DeletePermanentActionButton(source: ActionSource.viewer, useShortLabel: true)
else
const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
],
],
],
];
@ -112,4 +134,12 @@ class ViewerBottomBar extends ConsumerWidget {
),
);
}
void _updateView(WidgetRef ref) {
Future.delayed(Durations.extralong4, () {
if (ref.context.mounted) {
ref.read(assetViewerProvider.notifier).setControls(true);
}
});
}
}

View File

@ -9,6 +9,7 @@ import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@ -35,6 +36,7 @@ class ViewerKebabMenu extends ConsumerWidget {
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
final isWaitingForTrashApproval = ref.watch(isWaitingForTrashApprovalProvider(asset.checksum)).value == true;
final actionContext = ActionButtonContext(
asset: asset,
@ -48,6 +50,7 @@ class ViewerKebabMenu extends ConsumerWidget {
source: ActionSource.viewer,
isCasting: isCasting,
timelineOrigin: timelineOrigin,
isWaitingForTrashApproval: isWaitingForTrashApproval,
);
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref);

View File

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
@ -14,6 +15,8 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@ -47,6 +50,10 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final originalTheme = context.themeData;
final isWaitingForSyncApproval =
ref.read(timelineServiceProvider).origin == TimelineOrigin.syncTrash ||
ref.watch(isWaitingForTrashApprovalProvider(asset.checksum)).value == true;
final actions = <Widget>[
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
if (album != null && album.isActivityEnabled && album.isShared)
@ -63,9 +70,9 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
},
),
if (asset.hasRemote && isOwner && !asset.isFavorite)
if (asset.hasRemote && isOwner && !asset.isFavorite && !isWaitingForSyncApproval)
const FavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
if (asset.hasRemote && isOwner && asset.isFavorite)
if (asset.hasRemote && isOwner && asset.isFavorite && !isWaitingForSyncApproval)
const UnFavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
ViewerKebabMenu(originalTheme: originalTheme),

View File

@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/keep_on_device_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_trash_action_button.widget.dart';
class TrashSyncBottomBar extends ConsumerWidget {
const TrashSyncBottomBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: SizedBox(
height: 64,
child: Container(
color: context.themeData.canvasColor,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
KeepOnDeviceActionButton(
source: ActionSource.timeline,
onResult: (result) => showKeepResultToast(context, result),
),
MoveToTrashActionButton(
source: ActionSource.timeline,
onResult: (result) => showTrashResultToast(context, result),
),
],
),
),
),
),
);
}
}

View File

@ -566,6 +566,21 @@ class ActionNotifier extends Notifier<void> {
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> resolveRemoteTrash(ActionSource source, {required bool isSyncApproved}) async {
final selectedChecksums = _getAssets(source).map((a) => a.checksum).nonNulls;
_logger.info('resolveRemoteTrash, selectedChecksums: $selectedChecksums, isSyncApproved: $isSyncApproved');
if (selectedChecksums.isEmpty) {
return const ActionResult(count: 0, success: false, error: 'Failed to select asset(s)');
}
try {
final result = await _service.resolveRemoteTrash(selectedChecksums, isSyncApproved: isSyncApproved);
return ActionResult(count: result.displayCount, success: result.success);
} catch (error, stack) {
_logger.severe('Failed to ${isSyncApproved ? 'allow' : 'deny'} to move assets to trash', error, stack);
return ActionResult(count: selectedChecksums.length, success: false, error: error.toString());
}
}
}
extension on Iterable<RemoteAsset> {

View File

@ -10,7 +10,9 @@ import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
@ -20,10 +22,7 @@ final syncStreamServiceProvider = Provider(
(ref) => SyncStreamService(
syncApiRepository: ref.watch(syncApiRepositoryProvider),
syncStreamRepository: ref.watch(syncStreamRepositoryProvider),
localAssetRepository: ref.watch(localAssetRepository),
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
assetMediaRepository: ref.watch(assetMediaRepositoryProvider),
permissionRepository: ref.watch(permissionRepositoryProvider),
trashSyncService: ref.watch(trashSyncServiceProvider),
syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider),
api: ref.watch(apiServiceProvider),
cancelChecker: ref.watch(cancellationProvider),
@ -41,7 +40,9 @@ final localSyncServiceProvider = Provider(
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
assetMediaRepository: ref.watch(assetMediaRepositoryProvider),
permissionRepository: ref.watch(permissionRepositoryProvider),
trashSyncRepository: ref.watch(trashSyncRepositoryProvider),
nativeSyncApi: ref.watch(nativeSyncApiProvider),
metadataRepository: ref.watch(metadataProvider),
),
);

View File

@ -1,12 +1,49 @@
import 'package:async/async.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/trash_sync.model.dart';
import 'package:immich_mobile/domain/services/trash_sync.service.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
typedef TrashedAssetsCount = ({int total, int hashed});
final trashSyncRepositoryProvider = Provider<DriftTrashSyncRepository>(
(ref) => DriftTrashSyncRepository(ref.watch(driftProvider)),
);
final trashedAssetsCountProvider = StreamProvider<TrashedAssetsCount>((ref) {
final repo = ref.watch(trashedLocalAssetRepository);
final total$ = repo.watchCount();
final hashed$ = repo.watchHashedCount();
return StreamZip<int>([total$, hashed$]).map((values) => (total: values[0], hashed: values[1]));
});
final trashSyncServiceProvider = Provider(
(ref) => TrashSyncService(
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
trashSyncRepository: ref.watch(trashSyncRepositoryProvider),
assetMediaRepository: ref.watch(assetMediaRepositoryProvider),
permissionRepository: ref.watch(permissionRepositoryProvider),
metadataRepository: ref.watch(metadataProvider),
),
);
final outOfSyncAssetsCountProvider = StreamProvider<int>((ref) {
final enabledReviewMode = ref.watch(
appConfigProvider.select((config) => config.trashSync.mode == TrashSyncMode.review),
);
final service = ref.watch(trashSyncServiceProvider);
return enabledReviewMode ? service.watchPendingApprovalAssetCount() : Stream<int>.value(0);
});
final isWaitingForTrashApprovalProvider = StreamProvider.family<bool, String?>((ref, checksum) {
final enabledReviewMode = ref.watch(
appConfigProvider.select((config) => config.trashSync.mode == TrashSyncMode.review),
);
final service = ref.watch(trashSyncServiceProvider);
return enabledReviewMode && checksum != null ? service.watchIsAssetApprovalPending(checksum) : Stream.value(false);
});

View File

@ -63,6 +63,7 @@ import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_slideshow.page.dart';
import 'package:immich_mobile/presentation/pages/drift_trash.page.dart';
import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart';
import 'package:immich_mobile/presentation/pages/drift_trash_sync_review.page.dart';
import 'package:immich_mobile/presentation/pages/drift_video.page.dart';
import 'package:immich_mobile/presentation/pages/edit/drift_edit.page.dart';
import 'package:immich_mobile/presentation/pages/local_timeline.page.dart';
@ -163,6 +164,7 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DriftMemoryRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftFavoriteRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftTrashRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftTrashSyncReviewRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftArchiveRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftLockedFolderRoute.page, guards: [_authGuard, _lockedGuard, _duplicateGuard]),
AutoRoute(page: DriftVideoRoute.page, guards: [_authGuard, _duplicateGuard]),

View File

@ -1158,6 +1158,22 @@ class DriftTrashRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [DriftTrashSyncReviewPage]
class DriftTrashSyncReviewRoute extends PageRouteInfo<void> {
const DriftTrashSyncReviewRoute({List<PageRouteInfo>? children})
: super(DriftTrashSyncReviewRoute.name, initialChildren: children);
static const String name = 'DriftTrashSyncReviewRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const DriftTrashSyncReviewPage();
},
);
}
/// generated route for
/// [DriftUploadDetailPage]
class DriftUploadDetailRoute extends PageRouteInfo<void> {

View File

@ -6,16 +6,16 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/tag.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/download.repository.dart';
@ -25,6 +25,7 @@ import 'package:immich_mobile/utils/timezone.dart';
import 'package:immich_mobile/widgets/common/date_time_picker.dart';
import 'package:immich_mobile/widgets/common/location_picker.dart';
import 'package:immich_mobile/widgets/common/tag_picker.dart';
import 'package:logging/logging.dart';
import 'package:maplibre_gl/maplibre_gl.dart' as maplibre;
final actionServiceProvider = Provider<ActionService>(
@ -35,12 +36,16 @@ final actionServiceProvider = Provider<ActionService>(
ref.watch(driftAlbumApiRepositoryProvider),
ref.watch(remoteAlbumRepository),
ref.watch(trashedLocalAssetRepository),
ref.watch(trashSyncRepositoryProvider),
ref.watch(assetMediaRepositoryProvider),
ref.watch(downloadRepositoryProvider),
ref.watch(tagServiceProvider),
Logger('ActionService'),
),
);
typedef RemoteTrashResolveResult = ({int displayCount, bool success});
class ActionService {
final AssetApiRepository _assetApiRepository;
final RemoteAssetRepository _remoteAssetRepository;
@ -48,9 +53,11 @@ class ActionService {
final DriftAlbumApiRepository _albumApiRepository;
final DriftRemoteAlbumRepository _remoteAlbumRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final DriftTrashSyncRepository _trashSyncRepository;
final AssetMediaRepository _assetMediaRepository;
final DownloadRepository _downloadRepository;
final TagService _tagService;
final Logger _logger;
const ActionService(
this._assetApiRepository,
@ -59,9 +66,11 @@ class ActionService {
this._albumApiRepository,
this._remoteAlbumRepository,
this._trashedLocalAssetRepository,
this._trashSyncRepository,
this._assetMediaRepository,
this._downloadRepository,
this._tagService,
this._logger,
);
Future<void> shareLink(List<String> remoteIds, BuildContext context) async {
@ -307,11 +316,48 @@ class ActionService {
if (deletedIds.isEmpty) {
return 0;
}
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
if (CurrentPlatform.isAndroid) {
await _trashedLocalAssetRepository.applyTrashedAssets(deletedIds);
} else {
await _localAssetRepository.delete(deletedIds);
}
return deletedIds.length;
}
Future<RemoteTrashResolveResult> resolveRemoteTrash(
Iterable<String> trashedChecksums, {
required bool isSyncApproved,
}) async {
if (!isSyncApproved) {
await _trashSyncRepository.updateApproves(trashedChecksums, false);
return (displayCount: trashedChecksums.length, success: true);
}
final assetsToTrash = await _trashSyncRepository.getTrashSyncMoveCandidates(trashedChecksums);
if (assetsToTrash.isEmpty) {
// No localAssetEntity found; close review to avoid re-showing the same items.
await _trashSyncRepository.updateApproves(trashedChecksums, true);
return (displayCount: trashedChecksums.length, success: true);
}
final localIds = assetsToTrash.map((item) => item.candidate.asset.id).toSet().toList();
_logger.info("Moving assets to trash: ${localIds.join(", ")}");
final deletedIds = await _assetMediaRepository.deleteAll(localIds);
if (deletedIds.isEmpty) {
return (displayCount: 0, success: false);
}
final deletedIdLookup = deletedIds.toSet();
final deletedAssetsToTrash = assetsToTrash.where((item) => deletedIdLookup.contains(item.candidate.asset.id));
final resolvedChecksums = deletedAssetsToTrash.map((item) => item.candidate.asset.checksum!).toSet();
await _trashSyncRepository.transaction<void>(() async {
if (CurrentPlatform.isAndroid) {
await _trashedLocalAssetRepository.trashLocalAssets(deletedAssetsToTrash);
} else {
await _localAssetRepository.delete(deletedIds);
}
await _trashSyncRepository.updateApproves(resolvedChecksums, true);
});
return (displayCount: resolvedChecksums.length, success: resolvedChecksums.length == trashedChecksums.length);
}
}

View File

@ -3,7 +3,6 @@ import 'package:immich_mobile/entities/store.entity.dart';
enum AppSettingsEnum<T> {
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false);

View File

@ -12,11 +12,13 @@ class ImmichTheme {
ThemeData getThemeData({required ColorScheme colorScheme, required Locale locale}) {
final isDark = colorScheme.brightness == Brightness.dark;
final warningColor = isDark ? const Color(0xFFF3BC6A) : const Color(0xFFC47A00);
final onWarningColor = isDark ? Colors.black : Colors.white;
return ThemeData(
useMaterial3: true,
brightness: colorScheme.brightness,
colorScheme: colorScheme,
colorScheme: colorScheme.copyWith(tertiary: warningColor, onTertiary: onWarningColor),
primaryColor: colorScheme.primary,
hintColor: colorScheme.onSurfaceSecondary,
focusColor: colorScheme.primary,

View File

@ -47,6 +47,7 @@ class ActionButtonContext {
final bool isCasting;
final TimelineOrigin timelineOrigin;
final int selectedCount;
final bool isWaitingForTrashApproval;
const ActionButtonContext({
required this.asset,
@ -61,6 +62,7 @@ class ActionButtonContext {
this.isCasting = false,
this.timelineOrigin = TimelineOrigin.main,
this.selectedCount = 1,
this.isWaitingForTrashApproval = false,
});
}
@ -102,7 +104,8 @@ enum ActionButtonType {
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote && //
!context.isArchived,
!context.isArchived &&
!context.isWaitingForTrashApproval,
ActionButtonType.unarchive =>
context.isOwner && //
!context.isInLockedView && //
@ -117,31 +120,37 @@ enum ActionButtonType {
!context.isInLockedView && //
context.asset.hasRemote && //
context.isTrashEnabled && //
context.timelineOrigin != TimelineOrigin.trash,
context.timelineOrigin != TimelineOrigin.trash &&
!context.isWaitingForTrashApproval,
ActionButtonType.restoreTrash =>
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote && //
context.timelineOrigin == TimelineOrigin.trash,
context.timelineOrigin == TimelineOrigin.trash &&
!context.isWaitingForTrashApproval,
ActionButtonType.deletePermanent =>
context.isOwner && //
context.asset.hasRemote && //
(!context.isTrashEnabled || context.timelineOrigin == TimelineOrigin.trash || context.isInLockedView),
(!context.isTrashEnabled || context.timelineOrigin == TimelineOrigin.trash || context.isInLockedView) &&
!context.isWaitingForTrashApproval,
ActionButtonType.delete =>
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote,
context.asset.hasRemote &&
!context.isWaitingForTrashApproval,
ActionButtonType.moveToLockFolder =>
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote,
context.asset.hasRemote &&
!context.isWaitingForTrashApproval,
ActionButtonType.removeFromLockFolder =>
context.isOwner && //
context.isInLockedView && //
context.asset.hasRemote,
ActionButtonType.deleteLocal =>
!context.isInLockedView && //
context.asset.hasLocal,
context.asset.hasLocal &&
!context.isWaitingForTrashApproval,
ActionButtonType.upload =>
!context.isInLockedView && //
context.asset.storage == AssetState.local,
@ -179,6 +188,7 @@ enum ActionButtonType {
context.timelineOrigin != TimelineOrigin.lockedFolder &&
context.timelineOrigin != TimelineOrigin.archive &&
context.timelineOrigin != TimelineOrigin.localAlbum &&
context.timelineOrigin != TimelineOrigin.syncTrash &&
context.isOwner,
ActionButtonType.cast => context.isCasting || context.asset.hasRemote,
ActionButtonType.slideshow => true,

View File

@ -11,17 +11,20 @@ import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/models/trash_sync.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
const int targetVersion = 26;
const int targetVersion = 27;
Future<void> migrateDatabaseIfNeeded(Drift drift) async {
final int version = Store.get(StoreKey.version, targetVersion);
var migratedMetadata = false;
if (version < 25) {
await _migrateTo25();
@ -29,9 +32,18 @@ Future<void> migrateDatabaseIfNeeded(Drift drift) async {
if (version < 26) {
await _migrateTo26(drift);
migratedMetadata = true;
}
if (version < 27) {
await _migrateTo27(drift);
migratedMetadata = true;
}
await Store.put(StoreKey.version, targetVersion);
if (migratedMetadata) {
await MetadataRepository.instance.refresh();
}
return;
}
@ -136,6 +148,33 @@ Future<void> _migrateTo26(Drift drift) async {
await migrator.complete();
}
Future<void> _migrateTo27(Drift drift) async {
final key = MetadataKey.trashSyncMode;
final existing = await (drift.select(
drift.metadataEntity,
)..where((row) => row.key.equals(key.name))).getSingleOrNull();
if (existing == null) {
final legacy = await (drift.select(
drift.storeEntity,
)..where((row) => row.id.equals(StoreKey.manageLocalMediaAndroid.id) & row.intValue.equals(1))).getSingleOrNull();
if (legacy != null) {
await drift
.into(drift.metadataEntity)
.insertOnConflictUpdate(
MetadataEntityCompanion.insert(
key: key.name,
value: key.encode(TrashSyncMode.autoSync),
updatedAt: Value(DateTime.now()),
),
);
}
}
await (drift.delete(drift.storeEntity)..where((row) => row.id.equals(StoreKey.manageLocalMediaAndroid.id))).go();
}
Future<void> _migrateAlbumSortMode(_StoreMigrator migrator) async {
final raw = await migrator.readLegacyStoreInt(StoreKey.legacySelectedAlbumSortOrder.id);
final mode = AlbumSortMode.values.firstWhereOrNull((e) => raw != null && e.storeIndex == raw);

View File

@ -6,11 +6,13 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/server_info/server_disk_info.model.dart';
import 'package:immich_mobile/pages/common/settings.page.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/locale_provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
@ -68,19 +70,24 @@ class ImmichAppBarDialog extends HookConsumerWidget {
);
}
buildActionButton(IconData icon, String text, Function() onTap, {Widget? trailing}) {
buildActionButton(IconData icon, String text, Function() onTap, {Widget? trailing, Color? btnColor}) {
return ListTile(
dense: true,
visualDensity: VisualDensity.standard,
contentPadding: const EdgeInsets.only(left: 30, right: 30),
minLeadingWidth: 40,
leading: SizedBox(child: Icon(icon, color: theme.textTheme.labelLarge?.color?.withAlpha(250), size: 20)),
leading: SizedBox(
child: Icon(icon, color: btnColor ?? theme.textTheme.labelLarge?.color?.withAlpha(250), size: 20),
),
title: Text(
text,
style: theme.textTheme.labelLarge?.copyWith(color: theme.textTheme.labelLarge?.color?.withAlpha(250)),
style: theme.textTheme.labelLarge?.copyWith(
color: btnColor ?? theme.textTheme.labelLarge?.color?.withAlpha(250),
),
).tr(),
onTap: onTap,
trailing: trailing,
iconColor: btnColor,
);
}
@ -96,6 +103,25 @@ class ImmichAppBarDialog extends HookConsumerWidget {
);
}
Widget buildOutOfSyncButton() {
return Consumer(
builder: (context, ref, _) {
final outOfSyncCount = ref.watch(outOfSyncAssetsCountProvider).value ?? 0;
if (outOfSyncCount == 0) {
return const SizedBox.shrink();
}
final btnColor = theme.colorScheme.tertiary;
return buildActionButton(
Icons.warning_amber_rounded,
'review_out_of_sync_changes'.t(),
() => context.pushRoute(const DriftTrashSyncReviewRoute()),
trailing: Text('($outOfSyncCount)', style: theme.textTheme.labelLarge?.copyWith(color: btnColor)),
btnColor: btnColor,
);
},
);
}
buildAppLogButton() {
return buildActionButton(
Icons.assignment_outlined,
@ -269,6 +295,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
],
),
),
buildOutOfSyncButton(),
if (isReadonlyModeEnabled) buildReadonlyMessage(),
buildAppLogButton(),
buildFreeUpSpaceButton(),

View File

@ -12,6 +12,7 @@ import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/sync_status.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
@ -111,6 +112,7 @@ class _ProfileIndicator extends ConsumerWidget {
// TODO: remove this when update Flutter version newer than 3.35.7
final isIpad = defaultTargetPlatform == TargetPlatform.iOS && !context.isMobile;
final outOfSyncCount = ref.watch(outOfSyncAssetsCountProvider).value ?? 0;
void toggleReadonlyMode() {
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
ref.read(readonlyModeProvider.notifier).toggleReadonlyMode();
@ -147,7 +149,7 @@ class _ProfileIndicator extends ConsumerWidget {
),
backgroundColor: Colors.transparent,
alignment: Alignment.bottomRight,
isLabelVisible: versionWarningPresent,
isLabelVisible: versionWarningPresent || outOfSyncCount > 0,
offset: const Offset(-2, -12),
child: user == null
? const Icon(Icons.face_outlined, size: widgetSize)

View File

@ -11,8 +11,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/domain/models/trash_sync.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
@ -239,7 +238,8 @@ class LoginForm extends HookConsumerWidget {
}
}
bool isSyncRemoteDeletionsMode() => Platform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false);
bool isSyncRemoteDeletionsMode() =>
Platform.isAndroid && MetadataRepository.instance.appConfig.trashSync.mode == TrashSyncMode.autoSync;
login() async {
TextInput.finishAutofillContext();

View File

@ -5,6 +5,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/trash_sync.model.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
@ -16,8 +17,10 @@ import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custom_proxy_headers_settings.dart';
import 'package:immich_mobile/widgets/settings/settings_action_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_radio_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
import 'package:immich_mobile/widgets/settings/ssl_client_cert_settings.dart';
import 'package:logging/logging.dart';
@ -28,9 +31,7 @@ class AdvancedSettings extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final advancedTroubleshooting = useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
final isManageMediaSupported = useState(false);
final manageMediaAndroidPermission = useState(false);
final levelId = useState<int>(ref.read(appConfigProvider).logLevel.index);
final preferRemote = useState(ref.read(appConfigProvider).image.preferRemote);
useValueChanged(
@ -56,9 +57,6 @@ class AdvancedSettings extends HookConsumerWidget {
useEffect(() {
() async {
isManageMediaSupported.value = await checkAndroidVersion();
if (isManageMediaSupported.value) {
manageMediaAndroidPermission.value = await ref.read(permissionRepositoryProvider).hasManageMediaPermission();
}
}();
return null;
}, []);
@ -70,36 +68,11 @@ class AdvancedSettings extends HookConsumerWidget {
title: "advanced_settings_troubleshooting_title".tr(),
subtitle: "advanced_settings_troubleshooting_subtitle".tr(),
),
if (isManageMediaSupported.value)
Column(
children: [
SettingsSwitchListTile(
enabled: true,
valueNotifier: manageLocalMediaAndroid,
title: "advanced_settings_sync_remote_deletions_title".tr(),
subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(),
onChanged: (value) async {
if (value) {
final result = await ref.read(permissionRepositoryProvider).requestManageMediaPermission();
manageLocalMediaAndroid.value = result;
manageMediaAndroidPermission.value = result;
}
},
),
SettingsActionTile(
title: "manage_media_access_title".tr(),
statusText: manageMediaAndroidPermission.value ? "allowed".tr() : "not_allowed".tr(),
subtitle: "manage_media_access_rationale".tr(),
statusColor: manageLocalMediaAndroid.value && !manageMediaAndroidPermission.value
? const Color.fromARGB(255, 243, 188, 106)
: null,
onActionTap: () async {
final result = await ref.read(permissionRepositoryProvider).manageMediaPermission();
manageMediaAndroidPermission.value = result;
},
),
],
),
// Android 12+: full selector (Off / Auto sync / Review) + MANAGE_MEDIA tile.
// iOS: reduced selector (Off / Review) no MANAGE_MEDIA on this
// platform; auto-sync is dropped because PhotoKit prompts on
// every batch, which would defeat the "set and forget" intent.
if (isManageMediaSupported.value || Platform.isIOS) const _TrashSyncModeSelector(),
SettingsSliderListTile(
text: "advanced_settings_log_level_title".tr(namedArgs: {'level': logLevel}),
valueNotifier: levelId,
@ -176,3 +149,127 @@ class AdvancedSettings extends HookConsumerWidget {
return SettingsSubPageScaffold(settings: advancedSettings);
}
}
final _manageMediaPermissionProvider = FutureProvider<bool>((ref) async {
return ref.watch(permissionRepositoryProvider).hasManageMediaPermission();
});
class _TrashSyncModeSelector extends HookConsumerWidget {
const _TrashSyncModeSelector();
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedTrashSyncMode = ref.watch(appConfigProvider.select((config) => config.trashSync.mode));
final manageMediaAndroidPermission = ref.watch(_manageMediaPermissionProvider);
final manageMediaAndroidPermissionValue = manageMediaAndroidPermission.valueOrNull;
final isTrashSyncEnabled = selectedTrashSyncMode != TrashSyncMode.off;
final reviewRemoteDeletionsSubtitle = [
"advanced_settings_review_remote_deletions_subtitle".tr(),
if (Platform.isAndroid) "advanced_settings_review_remote_deletions_subtitle_android".tr(),
].join(' ');
void showManageMediaRequiredSnackBar() {
if (!context.mounted) {
return;
}
context.scaffoldMessenger.showSnackBar(
SnackBar(
duration: const Duration(seconds: 3),
content: Text(
"manage_media_access_review_rationale".tr(),
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
),
),
);
}
Future<void> setTrashSyncMode(TrashSyncMode mode) {
return ref.read(metadataProvider).write(.trashSyncMode, mode);
}
Future<void> attemptToEnableSetting(TrashSyncMode mode) async {
if (Platform.isIOS) {
// No MANAGE_MEDIA on iOS; review is the only mode the user can pick.
if (mode == TrashSyncMode.review) {
await setTrashSyncMode(TrashSyncMode.review);
}
return;
}
final result = await ref.read(permissionRepositoryProvider).requestManageMediaPermission();
ref.invalidate(_manageMediaPermissionProvider);
if (mode == TrashSyncMode.autoSync && result) {
await setTrashSyncMode(TrashSyncMode.autoSync);
}
if (mode == TrashSyncMode.review) {
await setTrashSyncMode(TrashSyncMode.review);
if (!result) {
showManageMediaRequiredSnackBar();
}
}
}
Future<void> handleTrashSyncModeChange(TrashSyncMode? mode) async {
if (mode == null || mode == selectedTrashSyncMode) {
return;
}
if (mode == TrashSyncMode.off) {
await setTrashSyncMode(TrashSyncMode.off);
return;
}
await attemptToEnableSetting(mode);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SettingsSubTitle(title: "advanced_settings_sync_remote_deletions_selector_title".tr()),
SettingsRadioListTile(
groups: [
SettingsRadioGroup(
title: 'off'.tr(),
subtitle: 'advanced_settings_sync_remote_deletions_off_subtitle'.tr(),
value: TrashSyncMode.off,
),
// Auto-sync requires MANAGE_MEDIA to run silently. iOS has no
// equivalent permission and every batch would trigger a PhotoKit
// prompt so the auto mode is intentionally hidden there.
if (!Platform.isIOS)
SettingsRadioGroup(
title: 'advanced_settings_sync_remote_deletions_title'.tr(),
subtitle: 'advanced_settings_sync_remote_deletions_subtitle'.tr(),
value: TrashSyncMode.autoSync,
),
SettingsRadioGroup(
title: 'advanced_settings_review_remote_deletions_title'.tr(),
subtitle: reviewRemoteDeletionsSubtitle,
value: TrashSyncMode.review,
),
],
groupBy: selectedTrashSyncMode,
onRadioChanged: handleTrashSyncModeChange,
),
// MANAGE_MEDIA permission tile is Android-only; iOS has no equivalent.
if (Platform.isAndroid)
SettingsActionTile(
title: "manage_media_access_title".tr(),
statusText: manageMediaAndroidPermissionValue == null
? null
: manageMediaAndroidPermissionValue == true
? "allowed".tr()
: "not_allowed".tr(),
subtitle: "manage_media_access_rationale".tr(),
statusColor: manageMediaAndroidPermissionValue == false && isTrashSyncEnabled
? const Color.fromARGB(255, 243, 188, 106)
: null,
onActionTap: () async {
await ref.read(permissionRepositoryProvider).manageMediaPermission();
ref.invalidate(_manageMediaPermissionProvider);
},
),
],
);
}
}

View File

@ -1,13 +1,13 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
@ -17,7 +17,6 @@ import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/sync_status.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/settings/beta_sync_settings/entity_count_tile.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/setting_list_tile.dart';
@ -219,7 +218,6 @@ class _SyncStatsCounts extends ConsumerWidget {
final localAlbumService = ref.watch(localAlbumServiceProvider);
final remoteAlbumService = ref.watch(remoteAlbumServiceProvider);
final memoryService = ref.watch(driftMemoryServiceProvider);
final appSettingsService = ref.watch(appSettingsServiceProvider);
Future<List<dynamic>> loadCounts() async {
final assetCounts = assetService.getAssetCounts();
@ -354,8 +352,7 @@ class _SyncStatsCounts extends ConsumerWidget {
),
),
// To be removed once the experimental feature is stable
if (CurrentPlatform.isAndroid &&
appSettingsService.getSetting<bool>(AppSettingsEnum.manageLocalMediaAndroid)) ...[
if ((kDebugMode || kProfileMode) && CurrentPlatform.isAndroid) ...[
SettingGroupTitle(title: "trash".t(context: context)),
Consumer(
builder: (context, ref, _) {

View File

@ -1,11 +1,13 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
class SettingsRadioGroup<T> {
final String title;
final String? subtitle;
final T value;
const SettingsRadioGroup({required this.title, required this.value});
const SettingsRadioGroup({required this.title, this.subtitle, required this.value});
}
class SettingsRadioListTile<T> extends StatelessWidget {
@ -28,6 +30,12 @@ class SettingsRadioListTile<T> extends StatelessWidget {
dense: true,
activeColor: context.primaryColor,
title: Text(g.title, style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
subtitle: g.subtitle != null
? Text(
g.subtitle!,
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
)
: null,
value: g.value,
controlAffinity: ListTileControlAffinity.trailing,
),

View File

@ -2,8 +2,11 @@ import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/config/trash_sync_config.dart';
import 'package:immich_mobile/domain/models/trash_sync.model.dart';
import 'package:immich_mobile/domain/services/local_sync.service.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
@ -11,9 +14,9 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:mocktail/mocktail.dart';
import '../../domain/service.mock.dart';
@ -26,14 +29,23 @@ void main() {
late DriftLocalAlbumRepository mockLocalAlbumRepository;
late DriftLocalAssetRepository mockLocalAssetRepository;
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository;
late AssetMediaRepository mockAssetMediaRepository;
late MockPermissionRepository mockPermissionRepository;
late DriftTrashSyncRepository mockTrashSyncRepo;
late MockAssetMediaRepository mockAssetMediaRepository;
late MockNativeSyncApi mockNativeSyncApi;
late MockMetadataRepository mockMetadataRepository;
late Drift db;
late bool hasManageMediaPermission;
late TrashSyncMode trashSyncMode;
setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized();
debugDefaultTargetPlatformOverride = TargetPlatform.android;
registerFallbackValue(LocalAssetStub.image1);
registerFallbackValue(<LocalAsset>[]);
registerFallbackValue(<LocalAlbum>[]);
registerFallbackValue(<String>[]);
registerFallbackValue(<String, List<String>>{});
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: DriftStoreRepository(db));
@ -46,23 +58,31 @@ void main() {
});
setUp(() async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
mockLocalAlbumRepository = MockLocalAlbumRepository();
mockLocalAssetRepository = MockLocalAssetRepository();
mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository();
mockAssetMediaRepository = MockAssetMediaRepository();
mockPermissionRepository = MockPermissionRepository();
mockTrashSyncRepo = MockTrashSyncRepository();
mockNativeSyncApi = MockNativeSyncApi();
mockMetadataRepository = MockMetadataRepository();
trashSyncMode = TrashSyncMode.off;
when(
() => mockMetadataRepository.appConfig,
).thenAnswer((_) => AppConfig(trashSync: TrashSyncConfig(mode: trashSyncMode)));
when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => false);
when(() => mockNativeSyncApi.getMediaChanges()).thenAnswer(
(_) async => SyncDelta(hasChanges: false, updates: const [], deletes: const [], assetAlbums: const {}),
);
when(() => mockNativeSyncApi.getTrashedAssets()).thenAnswer((_) async => {});
when(() => mockNativeSyncApi.checkpointSync()).thenAnswer((_) async {});
when(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).thenAnswer((_) async {});
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []);
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {});
when(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any())).thenAnswer((_) async {});
when(() => mockTrashedLocalAssetRepository.trashLocalAsset(any())).thenAnswer((_) async {});
when(() => mockTrashSyncRepo.cleanupLocalTrashSync()).thenAnswer((_) async => 0);
when(() => mockAssetMediaRepository.deleteAll(any())).thenAnswer((invocation) async {
final ids = invocation.positionalArguments.first as List<String>;
return ids;
@ -75,34 +95,106 @@ void main() {
assetMediaRepository: mockAssetMediaRepository,
permissionRepository: mockPermissionRepository,
nativeSyncApi: mockNativeSyncApi,
trashSyncRepository: mockTrashSyncRepo,
metadataRepository: mockMetadataRepository,
);
await Store.put(StoreKey.manageLocalMediaAndroid, false);
await Store.clear();
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => false);
hasManageMediaPermission = false;
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => hasManageMediaPermission);
});
group('LocalSyncService - syncTrashedAssets gating', () {
test('invokes syncTrashedAssets when Android flag enabled and permission granted', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
trashSyncMode = TrashSyncMode.autoSync;
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
hasManageMediaPermission = true;
await sut.sync();
verify(() => mockNativeSyncApi.getTrashedAssets()).called(1);
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).called(1);
verifyNever(() => mockTrashSyncRepo.cleanupLocalTrashSync());
});
test('skips syncTrashedAssets when store flag disabled', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, false);
test('syncs trashed snapshot when store flags are disabled', () async {
trashSyncMode = TrashSyncMode.off;
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
hasManageMediaPermission = true;
await sut.sync();
verifyNever(() => mockNativeSyncApi.getTrashedAssets());
verify(() => mockNativeSyncApi.getTrashedAssets()).called(1);
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).called(1);
verifyNever(() => mockTrashSyncRepo.cleanupLocalTrashSync());
verifyNever(() => mockPermissionRepository.hasManageMediaPermission());
verifyNever(() => mockAssetMediaRepository.restoreAssetsFromTrash(any()));
});
test('skips syncTrashedAssets when MANAGE_MEDIA permission absent', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
test('syncs trashed snapshot but does not handle remote trash intents', () async {
trashSyncMode = TrashSyncMode.autoSync;
hasManageMediaPermission = false;
await sut.sync();
verify(() => mockNativeSyncApi.getTrashedAssets()).called(1);
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).called(1);
verifyNever(() => mockTrashSyncRepo.cleanupLocalTrashSync());
verifyNever(() => mockTrashSyncRepo.upsertReviewCandidates(any()));
verifyNever(() => mockAssetMediaRepository.restoreAssetsFromTrash(any()));
});
test('syncs trashed snapshot but skips review restore when MANAGE_MEDIA permission absent', () async {
trashSyncMode = TrashSyncMode.review;
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => [LocalAssetStub.image1]);
hasManageMediaPermission = false;
await sut.processTrashedAssets({});
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).called(1);
verifyNever(() => mockTrashSyncRepo.cleanupLocalTrashSync());
verify(() => mockPermissionRepository.hasManageMediaPermission()).called(1);
verifyNever(() => mockAssetMediaRepository.restoreAssetsFromTrash(any()));
});
test('cleans trash sync after Android full sync updates local assets', () async {
when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => true);
when(() => mockNativeSyncApi.getAlbums()).thenAnswer((_) async => []);
when(() => mockLocalAlbumRepository.getAll(sortBy: {SortLocalAlbumsBy.id})).thenAnswer((_) async => []);
await sut.sync();
verify(() => mockNativeSyncApi.getTrashedAssets()).called(1);
verify(() => mockTrashSyncRepo.cleanupLocalTrashSync()).called(1);
});
test('cleans trash sync after Android delta sync updates local assets', () async {
when(() => mockNativeSyncApi.getMediaChanges()).thenAnswer(
(_) async => SyncDelta(hasChanges: true, updates: const [], deletes: const [], assetAlbums: const {}),
);
when(() => mockNativeSyncApi.getAlbums()).thenAnswer((_) async => []);
when(() => mockLocalAlbumRepository.updateAll(any())).thenAnswer((_) async {});
when(
() => mockLocalAlbumRepository.processDelta(
updates: any(named: 'updates'),
deletes: any(named: 'deletes'),
assetAlbums: any(named: 'assetAlbums'),
),
).thenAnswer((_) async {});
when(() => mockLocalAlbumRepository.getAll()).thenAnswer((_) async => []);
await sut.sync();
verify(() => mockNativeSyncApi.getTrashedAssets()).called(1);
verify(() => mockTrashSyncRepo.cleanupLocalTrashSync()).called(1);
});
test('skips syncTrashedAssets on non-Android platforms', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
hasManageMediaPermission = false;
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => false);
await sut.sync();
@ -110,21 +202,59 @@ void main() {
verifyNever(() => mockNativeSyncApi.getTrashedAssets());
});
test('skips syncTrashedAssets on non-Android platforms', () async {
test('cleans trash sync after iOS delta sync updates local assets', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
when(() => mockNativeSyncApi.getMediaChanges()).thenAnswer(
(_) async => SyncDelta(hasChanges: true, updates: const [], deletes: const [], assetAlbums: const {}),
);
when(() => mockNativeSyncApi.getAlbums()).thenAnswer((_) async => []);
when(() => mockLocalAlbumRepository.updateAll(any())).thenAnswer((_) async {});
when(
() => mockLocalAlbumRepository.processDelta(
updates: any(named: 'updates'),
deletes: any(named: 'deletes'),
assetAlbums: any(named: 'assetAlbums'),
),
).thenAnswer((_) async {});
when(() => mockLocalAlbumRepository.getAll()).thenAnswer((_) async => []);
await Store.put(StoreKey.manageLocalMediaAndroid, true);
trashSyncMode = TrashSyncMode.autoSync;
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
await sut.sync();
verifyNever(() => mockNativeSyncApi.getTrashedAssets());
verify(() => mockTrashSyncRepo.cleanupLocalTrashSync()).called(1);
});
});
group('LocalSyncService - syncTrashedAssets behavior', () {
test('processes trashed snapshot, restores assets, and trashes local files', () async {
test('review mode only restores local trash and does not clean trash sync directly', () async {
trashSyncMode = TrashSyncMode.review;
final platformAsset = PlatformAsset(
id: 'remote-id',
name: 'remote.jpg',
type: AssetType.image.index,
durationMs: 0,
orientation: 0,
isFavorite: false,
playbackStyle: PlatformAssetPlaybackStyle.image,
);
await sut.processTrashedAssets({
'album-a': [platformAsset],
});
verifyNever(() => mockTrashSyncRepo.upsertReviewCandidates(any()));
verifyNever(() => mockTrashSyncRepo.cleanupLocalTrashSync());
});
test('processes trashed snapshot and restores assets', () async {
trashSyncMode = TrashSyncMode.autoSync;
hasManageMediaPermission = true;
final platformAsset = PlatformAsset(
id: 'remote-id',
name: 'remote.jpg',
@ -144,13 +274,6 @@ void main() {
return restoredIds;
});
final localAssetToTrash = LocalAssetStub.image2.copyWith(id: 'local-trash', checksum: 'checksum-trash');
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer(
(_) async => {
'album-a': [localAssetToTrash],
},
);
await sut.processTrashedAssets({
'album-a': [platformAsset],
});
@ -163,38 +286,9 @@ void main() {
expect(trashedEntry.albumId, 'album-a');
expect(trashedEntry.asset.id, platformAsset.id);
expect(trashedEntry.asset.name, platformAsset.name);
verify(() => mockTrashedLocalAssetRepository.getToTrash()).called(1);
verify(() => mockAssetMediaRepository.restoreAssetsFromTrash(any())).called(1);
verify(() => mockTrashedLocalAssetRepository.applyRestoredAssets(restoredIds)).called(1);
final moveArgs = verify(() => mockAssetMediaRepository.deleteAll(captureAny())).captured.single as List<String>;
expect(moveArgs, ['local-trash']);
final trashArgs =
verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single
as Map<String, List<LocalAsset>>;
expect(trashArgs.keys, ['album-a']);
expect(trashArgs['album-a'], [localAssetToTrash]);
});
test('records only local assets that were moved to device trash', () async {
final movedAsset = LocalAssetStub.image1.copyWith(id: 'moved-local', checksum: 'checksum-moved');
final skippedAsset = LocalAssetStub.image2.copyWith(id: 'skipped-local', checksum: 'checksum-skipped');
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer(
(_) async => {
'album-a': [movedAsset],
'album-b': [skippedAsset],
},
);
when(() => mockAssetMediaRepository.deleteAll(any())).thenAnswer((_) async => ['moved-local']);
await sut.processTrashedAssets({});
final trashArgs =
verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single
as Map<String, List<LocalAsset>>;
expect(trashArgs.keys, ['album-a']);
expect(trashArgs['album-a'], [movedAsset]);
});
test('does not attempt restore when repository has no assets to restore', () async {
@ -209,15 +303,6 @@ void main() {
verifyNever(() => mockAssetMediaRepository.restoreAssetsFromTrash(any()));
verifyNever(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any()));
});
test('does not move local assets when repository finds nothing to trash', () async {
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {});
await sut.processTrashedAssets({});
verifyNever(() => mockAssetMediaRepository.deleteAll(any()));
verifyNever(() => mockTrashedLocalAssetRepository.trashLocalAsset(any()));
});
});
group('LocalSyncService - PlatformAsset conversion', () {

View File

@ -4,28 +4,23 @@ import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/domain/services/sync_stream.service.dart';
import 'package:immich_mobile/domain/services/trash_sync.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart';
import '../../api.mocks.dart';
import '../../fixtures/asset.stub.dart';
import '../../fixtures/sync_stream.stub.dart';
import '../../infrastructure/repository.mock.dart';
import '../../repository.mocks.dart';
import '../../service.mocks.dart';
class _AbortCallbackWrapper {
@ -44,14 +39,13 @@ class _CancellationWrapper {
class _MockCancellationWrapper extends Mock implements _CancellationWrapper {}
class MockTrashSyncService extends Mock implements TrashSyncService {}
void main() {
late SyncStreamService sut;
late SyncStreamRepository mockSyncStreamRepo;
late SyncApiRepository mockSyncApiRepo;
late DriftLocalAssetRepository mockLocalAssetRepo;
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepo;
late AssetMediaRepository mockAssetMediaRepo;
late MockPermissionRepository mockPermissionRepo;
late TrashSyncService mockTrashSyncService;
late MockApiService mockApi;
late MockServerApi mockServerApi;
late MockSyncMigrationRepository mockSyncMigrationRepo;
@ -59,16 +53,16 @@ void main() {
late _MockAbortCallbackWrapper mockAbortCallbackWrapper;
late _MockAbortCallbackWrapper mockResetCallbackWrapper;
late Drift db;
late bool hasManageMediaPermission;
setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized();
debugDefaultTargetPlatformOverride = TargetPlatform.android;
registerFallbackValue(LocalAssetStub.image1);
registerFallbackValue(const SemVer(major: 2, minor: 5, patch: 0));
registerFallbackValue(<RemoteAssetTrashState>[]);
registerFallbackValue(<String, DateTime>{});
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: DriftStoreRepository(db));
await StoreService.init(storeRepository: DriftStoreRepository(db), listenUpdates: false);
});
tearDownAll(() async {
@ -82,10 +76,7 @@ void main() {
setUp(() async {
mockSyncStreamRepo = MockSyncStreamRepository();
mockSyncApiRepo = MockSyncApiRepository();
mockLocalAssetRepo = MockLocalAssetRepository();
mockTrashedLocalAssetRepo = MockTrashedLocalAssetRepository();
mockAssetMediaRepo = MockAssetMediaRepository();
mockPermissionRepo = MockPermissionRepository();
mockTrashSyncService = MockTrashSyncService();
mockAbortCallbackWrapper = _MockAbortCallbackWrapper();
mockResetCallbackWrapper = _MockAbortCallbackWrapper();
mockApi = MockApiService();
@ -151,30 +142,19 @@ void main() {
when(() => mockSyncStreamRepo.updateAssetFacesV1(any())).thenAnswer(successHandler);
when(() => mockSyncStreamRepo.deleteAssetFacesV1(any())).thenAnswer(successHandler);
when(() => mockSyncMigrationRepo.v20260128CopyExifWidthHeightToAsset()).thenAnswer(successHandler);
when(() => mockTrashSyncService.syncRemoteTrashState(any())).thenAnswer((_) async {});
when(() => mockTrashSyncService.applyRemoteRemovalToLocal(any())).thenAnswer((_) async {});
sut = SyncStreamService(
syncApiRepository: mockSyncApiRepo,
syncStreamRepository: mockSyncStreamRepo,
localAssetRepository: mockLocalAssetRepo,
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
assetMediaRepository: mockAssetMediaRepo,
permissionRepository: mockPermissionRepo,
trashSyncService: mockTrashSyncService,
api: mockApi,
syncMigrationRepository: mockSyncMigrationRepo,
);
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((_) async => {});
when(() => mockTrashedLocalAssetRepo.trashLocalAsset(any())).thenAnswer((_) async {});
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => []);
when(() => mockTrashedLocalAssetRepo.applyRestoredAssets(any())).thenAnswer((_) async {});
hasManageMediaPermission = false;
when(() => mockPermissionRepo.hasManageMediaPermission()).thenAnswer((_) async => hasManageMediaPermission);
when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((invocation) async {
final ids = invocation.positionalArguments.first as List<String>;
return ids;
});
when(() => mockAssetMediaRepo.restoreAssetsFromTrash(any())).thenAnswer((_) async => []);
await Store.put(StoreKey.manageLocalMediaAndroid, false);
await Store.put(StoreKey.reviewRemoteDeletions, false);
});
Future<void> simulateEvents(List<SyncEvent> events) async {
@ -239,10 +219,7 @@ void main() {
sut = SyncStreamService(
syncApiRepository: mockSyncApiRepo,
syncStreamRepository: mockSyncStreamRepo,
localAssetRepository: mockLocalAssetRepo,
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
assetMediaRepository: mockAssetMediaRepo,
permissionRepository: mockPermissionRepo,
trashSyncService: mockTrashSyncService,
cancelChecker: cancellationChecker.call,
api: mockApi,
syncMigrationRepository: mockSyncMigrationRepo,
@ -280,10 +257,7 @@ void main() {
sut = SyncStreamService(
syncApiRepository: mockSyncApiRepo,
syncStreamRepository: mockSyncStreamRepo,
localAssetRepository: mockLocalAssetRepo,
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
assetMediaRepository: mockAssetMediaRepo,
permissionRepository: mockPermissionRepo,
trashSyncService: mockTrashSyncService,
cancelChecker: cancellationChecker.call,
api: mockApi,
syncMigrationRepository: mockSyncMigrationRepo,
@ -396,161 +370,64 @@ void main() {
});
});
group("SyncStreamService - remote trash & restore", () {
setUp(() async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
hasManageMediaPermission = true;
});
tearDown(() async {
await Store.put(StoreKey.manageLocalMediaAndroid, false);
hasManageMediaPermission = false;
});
test("moves backed up local and merged assets to device trash when remote trash events are received", () async {
final localAsset = LocalAssetStub.image1.copyWith(id: 'local-only', checksum: 'checksum-local', remoteId: null);
final mergedAsset = LocalAssetStub.image2.copyWith(
id: 'merged-local',
checksum: 'checksum-merged',
remoteId: 'remote-merged',
);
final assetsByAlbum = {
'album-a': [localAsset],
'album-b': [mergedAsset],
};
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((invocation) async {
final Iterable<String> requestedRemoteIds = invocation.positionalArguments.first as Iterable<String>;
expect(requestedRemoteIds.toSet(), equals({'remote-1', 'remote-2', 'remote-3'}));
return assetsByAlbum;
});
when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((invocation) async {
final ids = invocation.positionalArguments.first as List<String>;
expect(ids, unorderedEquals(['local-only', 'merged-local']));
return ids;
});
group("SyncStreamService - trash event routing", () {
test("forwards remote asset trash state after asset updates are persisted", () async {
final events = [
SyncStreamStub.assetTrashed(
id: 'remote-1',
checksum: localAsset.checksum!,
checksum: 'checksum-1',
ack: 'asset-remote-local-1',
trashedAt: DateTime(2025, 5, 1),
),
SyncStreamStub.assetTrashed(
id: 'remote-2',
checksum: mergedAsset.checksum!,
checksum: 'checksum-2',
ack: 'asset-remote-merged-2',
trashedAt: DateTime(2025, 5, 2),
),
SyncStreamStub.assetTrashed(
id: 'remote-3',
checksum: 'checksum-remote-only',
ack: 'asset-remote-only-3',
trashedAt: DateTime(2025, 5, 3),
),
];
await simulateEvents(events);
final trashArgs =
verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(captureAny())).captured.single
as Map<String, List<LocalAsset>>;
expect(trashArgs.keys, unorderedEquals(['album-a', 'album-b']));
expect(trashArgs['album-a'], [localAsset]);
expect(trashArgs['album-b'], [mergedAsset]);
verify(() => mockAssetMediaRepo.deleteAll(any())).called(1);
verify(() => mockSyncApiRepo.ack(['asset-remote-only-3'])).called(1);
});
test("records only assets that were moved to device trash", () async {
final movedAsset = LocalAssetStub.image1.copyWith(id: 'moved-local', checksum: 'checksum-moved');
final skippedAsset = LocalAssetStub.image2.copyWith(id: 'skipped-local', checksum: 'checksum-skipped');
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer(
(_) async => {
'album-a': [movedAsset],
'album-b': [skippedAsset],
},
final states =
verify(() => mockTrashSyncService.syncRemoteTrashState(captureAny())).captured.single
as Iterable<RemoteAssetTrashState>;
expect(
states,
unorderedEquals([
(id: 'remote-1', deletedAt: DateTime(2025, 5, 1)),
(id: 'remote-2', deletedAt: DateTime(2025, 5, 2)),
]),
);
when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((_) async => ['moved-local']);
verify(() => mockSyncStreamRepo.updateAssetsV1(any())).called(1);
verify(() => mockSyncApiRepo.ack(['asset-remote-merged-2'])).called(1);
});
final events = [
SyncStreamStub.assetTrashed(
id: 'remote-moved',
checksum: movedAsset.checksum!,
ack: 'asset-remote-moved',
trashedAt: DateTime(2025, 5, 1),
),
SyncStreamStub.assetTrashed(
id: 'remote-skipped',
checksum: skippedAsset.checksum!,
ack: 'asset-remote-skipped',
trashedAt: DateTime(2025, 5, 2),
),
];
test("forwards trash state on non-Android platforms", () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
final events = [SyncStreamStub.assetModified(id: 'remote-1', checksum: 'checksum-1', ack: 'asset-mod-ack-1')];
await simulateEvents(events);
final trashArgs =
verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(captureAny())).captured.single
as Map<String, List<LocalAsset>>;
expect(trashArgs.keys, ['album-a']);
expect(trashArgs['album-a'], [movedAsset]);
final states =
verify(() => mockTrashSyncService.syncRemoteTrashState(captureAny())).captured.single
as Iterable<RemoteAssetTrashState>;
expect(states, [(id: 'remote-1', deletedAt: null)]);
});
test("skips device trashing when no local assets match the remote trash payload", () async {
final events = [
SyncStreamStub.assetTrashed(
id: 'remote-only',
checksum: 'checksum-only',
ack: 'asset-remote-only-9',
trashedAt: DateTime(2025, 6, 1),
),
];
await simulateEvents(events);
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
verifyNever(() => mockAssetMediaRepo.deleteAll(any()));
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any()));
});
test("requests local deletions lookup by remote ids for permanent remote delete events", () async {
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((invocation) async {
final Iterable<String> requestedRemoteIds = invocation.positionalArguments.first as Iterable<String>;
expect(requestedRemoteIds.toSet(), equals({'remote-asset'}));
return {};
});
test("forwards permanent remote delete events to trash sync before deleting remote assets locally", () async {
final events = [SyncStreamStub.assetDeleteV1];
await simulateEvents(events);
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
verifyNever(() => mockAssetMediaRepo.deleteAll(any()));
final lookup =
verify(() => mockTrashSyncService.applyRemoteRemovalToLocal(captureAny())).captured.single
as Map<String, DateTime>;
expect(lookup.keys.toList(), ['remote-asset']);
verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1);
});
test("restores trashed local assets once the matching remote assets leave the trash", () async {
final trashedAssets = [
LocalAssetStub.image1.copyWith(id: 'trashed-1', checksum: 'checksum-trash', remoteId: 'remote-1'),
];
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => trashedAssets);
final restoredIds = ['trashed-1'];
when(() => mockAssetMediaRepo.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
final Iterable<LocalAsset> requestedAssets = invocation.positionalArguments.first as Iterable<LocalAsset>;
expect(requestedAssets, orderedEquals(trashedAssets));
return restoredIds;
});
final events = [
SyncStreamStub.assetModified(id: 'remote-1', checksum: 'checksum-trash', ack: 'asset-remote-1-11'),
];
await simulateEvents(events);
verify(() => mockTrashedLocalAssetRepo.applyRestoredAssets(restoredIds)).called(1);
verify(() => mockSyncApiRepo.ack(['asset-delete-ack'])).called(1);
});
});

View File

@ -0,0 +1,315 @@
import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset/remote_deleted_local_asset.model.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/config/trash_sync_config.dart';
import 'package:immich_mobile/domain/models/trash_sync.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/domain/services/trash_sync.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:mocktail/mocktail.dart';
import '../../fixtures/asset.stub.dart';
import '../../infrastructure/repository.mock.dart';
import '../../repository.mocks.dart';
void main() {
late TrashSyncService sut;
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepo;
late DriftTrashSyncRepository mockTrashSyncRepo;
late MockAssetMediaRepository mockAssetMediaRepo;
late MockPermissionRepository mockPermissionRepo;
late MockMetadataRepository mockMetadataRepository;
late Drift db;
late bool hasManageMediaPermission;
late TrashSyncMode trashSyncMode;
setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized();
debugDefaultTargetPlatformOverride = TargetPlatform.android;
registerFallbackValue(LocalAssetStub.image1);
registerFallbackValue(RemoteDeletedLocalAsset(asset: LocalAssetStub.image1, remoteDeletedAt: DateTime(2025, 1, 1)));
registerFallbackValue(<RemoteDeletedLocalAsset>[]);
registerFallbackValue(<String, DateTime>{});
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: DriftStoreRepository(db), listenUpdates: false);
});
tearDownAll(() async {
debugDefaultTargetPlatformOverride = null;
await Store.clear();
await db.close();
});
setUp(() async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
mockTrashedLocalAssetRepo = MockTrashedLocalAssetRepository();
mockTrashSyncRepo = MockTrashSyncRepository();
mockAssetMediaRepo = MockAssetMediaRepository();
mockPermissionRepo = MockPermissionRepository();
mockMetadataRepository = MockMetadataRepository();
trashSyncMode = TrashSyncMode.off;
when(
() => mockMetadataRepository.appConfig,
).thenAnswer((_) => AppConfig(trashSync: TrashSyncConfig(mode: trashSyncMode)));
sut = TrashSyncService(
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
trashSyncRepository: mockTrashSyncRepo,
assetMediaRepository: mockAssetMediaRepo,
permissionRepository: mockPermissionRepo,
metadataRepository: mockMetadataRepository,
);
when(
() => mockTrashSyncRepo.getRemoteTrashCandidates(any<Map<String, DateTime>>()),
).thenAnswer((_) async => <RemoteDeletedLocalAsset>[]);
when(
() => mockTrashSyncRepo.getSelectedBackupRemoteTrashMoveCandidates(any<Iterable<RemoteDeletedLocalAsset>>()),
).thenAnswer((_) async => <RemoteTrashMoveCandidate>[]);
when(() => mockTrashedLocalAssetRepo.trashLocalAssets(any())).thenAnswer((_) async {});
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => <LocalAsset>[]);
when(() => mockTrashedLocalAssetRepo.applyRestoredAssets(any())).thenAnswer((_) async {});
hasManageMediaPermission = false;
when(() => mockPermissionRepo.hasManageMediaPermission()).thenAnswer((_) async => hasManageMediaPermission);
when(() => mockAssetMediaRepo.restoreAssetsFromTrash(any())).thenAnswer((_) async => <String>[]);
when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((invocation) async {
return (invocation.positionalArguments.first as List<String>).toList();
});
when(() => mockTrashSyncRepo.upsertReviewCandidates(any())).thenAnswer((_) async {});
when(() => mockTrashSyncRepo.deleteOutdated(any())).thenAnswer((_) async => 0);
when(() => mockTrashSyncRepo.deleteResolved(any())).thenAnswer((_) async => 0);
when(() => mockTrashSyncRepo.transaction<void>(any())).thenAnswer((invocation) {
final callback = invocation.positionalArguments.first as Future<void> Function();
return callback();
});
});
group("TrashSyncService - remote trash & restore", () {
test("moves backed up local and merged assets to device trash when remote trash events are received", () async {
trashSyncMode = TrashSyncMode.autoSync;
hasManageMediaPermission = true;
final localAsset = LocalAssetStub.image1.copyWith(id: 'local-only', checksum: 'checksum-local', remoteId: null);
final mergedAsset = LocalAssetStub.image2.copyWith(
id: 'merged-local',
checksum: 'checksum-merged',
remoteId: 'remote-merged',
);
final candidates = [
RemoteDeletedLocalAsset(asset: localAsset, remoteDeletedAt: DateTime(2025, 5, 1)),
RemoteDeletedLocalAsset(asset: mergedAsset, remoteDeletedAt: DateTime(2025, 5, 2)),
];
final assetsByAlbum = {
'album-a': [RemoteDeletedLocalAsset(asset: localAsset, remoteDeletedAt: DateTime(2025, 5, 1))],
'album-b': [RemoteDeletedLocalAsset(asset: mergedAsset, remoteDeletedAt: DateTime(2025, 5, 2))],
};
when(() => mockTrashSyncRepo.getRemoteTrashCandidates(any<Map<String, DateTime>>())).thenAnswer((
invocation,
) async {
final trashedAssetsMap = invocation.positionalArguments.first as Map<String, DateTime>;
expect(
trashedAssetsMap,
equals({
'remote-1': DateTime(2025, 5, 1),
'remote-2': DateTime(2025, 5, 2),
'remote-3': DateTime(2025, 5, 3),
}),
);
return candidates;
});
when(
() => mockTrashSyncRepo.getSelectedBackupRemoteTrashMoveCandidates(any<Iterable<RemoteDeletedLocalAsset>>()),
).thenAnswer(
(_) async => assetsByAlbum.entries
.expand((entry) => entry.value.map((candidate) => (albumId: entry.key, candidate: candidate)))
.toList(),
);
when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((invocation) async {
final ids = invocation.positionalArguments.first as List<String>;
expect(ids, unorderedEquals(['local-only', 'merged-local']));
return ids;
});
await sut.syncRemoteTrashState([
(id: 'remote-1', deletedAt: DateTime(2025, 5, 1)),
(id: 'remote-2', deletedAt: DateTime(2025, 5, 2)),
(id: 'remote-3', deletedAt: DateTime(2025, 5, 3)),
]);
verifyNever(() => mockTrashSyncRepo.upsertReviewCandidates(any()));
final trashedAssets =
verify(() => mockTrashedLocalAssetRepo.trashLocalAssets(captureAny())).captured.single
as Iterable<RemoteTrashMoveCandidate>;
expect(trashedAssets.map((item) => item.albumId), unorderedEquals(['album-a', 'album-b']));
expect(trashedAssets.map((item) => item.candidate.asset.id), unorderedEquals(['local-only', 'merged-local']));
final resolvedChecksums =
verify(() => mockTrashSyncRepo.deleteResolved(captureAny())).captured.single as Iterable<String>;
expect(resolvedChecksums, unorderedEquals(['checksum-local', 'checksum-merged']));
});
test("records only unresolved candidates after automatic trash move", () async {
trashSyncMode = TrashSyncMode.autoSync;
hasManageMediaPermission = true;
final resolvedAsset = LocalAssetStub.image1.copyWith(id: 'resolved-local', checksum: 'checksum-resolved');
final unresolvedAsset = LocalAssetStub.image2.copyWith(id: 'unresolved-local', checksum: 'checksum-unresolved');
final candidates = [
RemoteDeletedLocalAsset(asset: resolvedAsset, remoteDeletedAt: DateTime(2025, 5, 1)),
RemoteDeletedLocalAsset(asset: unresolvedAsset, remoteDeletedAt: DateTime(2025, 5, 2)),
];
when(
() => mockTrashSyncRepo.getRemoteTrashCandidates(any<Map<String, DateTime>>()),
).thenAnswer((_) async => candidates);
when(
() => mockTrashSyncRepo.getSelectedBackupRemoteTrashMoveCandidates(any<Iterable<RemoteDeletedLocalAsset>>()),
).thenAnswer((_) async => candidates.map((candidate) => (albumId: 'album-a', candidate: candidate)).toList());
when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((_) async => ['resolved-local']);
await sut.syncRemoteTrashState([
(id: 'remote-1', deletedAt: DateTime(2025, 5, 1)),
(id: 'remote-2', deletedAt: DateTime(2025, 5, 2)),
]);
final trashedAssets =
verify(() => mockTrashedLocalAssetRepo.trashLocalAssets(captureAny())).captured.single
as Iterable<RemoteTrashMoveCandidate>;
expect(trashedAssets.map((item) => item.albumId), ['album-a']);
expect(trashedAssets.map((item) => item.candidate.asset.id), ['resolved-local']);
final resolvedChecksums =
verify(() => mockTrashSyncRepo.deleteResolved(captureAny())).captured.single as Iterable<String>;
expect(resolvedChecksums, ['checksum-resolved']);
final reviewCandidates =
verify(
() => mockTrashSyncRepo.upsertReviewCandidates(captureAny<Iterable<RemoteDeletedLocalAsset>>()),
).captured.single
as Iterable<RemoteDeletedLocalAsset>;
expect(reviewCandidates.map((item) => item.asset.id), ['unresolved-local']);
});
test("records all candidates when automatic trash move returns no moved ids", () async {
trashSyncMode = TrashSyncMode.autoSync;
hasManageMediaPermission = true;
final localAsset = LocalAssetStub.image1.copyWith(id: 'local-only', checksum: 'checksum-failed');
final candidates = [RemoteDeletedLocalAsset(asset: localAsset, remoteDeletedAt: DateTime(2025, 5, 1))];
when(
() => mockTrashSyncRepo.getRemoteTrashCandidates(any<Map<String, DateTime>>()),
).thenAnswer((_) async => candidates);
when(
() => mockTrashSyncRepo.getSelectedBackupRemoteTrashMoveCandidates(any<Iterable<RemoteDeletedLocalAsset>>()),
).thenAnswer((_) async => candidates.map((candidate) => (albumId: 'album-a', candidate: candidate)).toList());
when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((_) async => <String>[]);
await sut.syncRemoteTrashState([(id: 'remote-1', deletedAt: DateTime(2025, 5, 1))]);
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAssets(any()));
verifyNever(() => mockTrashSyncRepo.deleteResolved(any()));
final reviewCandidates =
verify(
() => mockTrashSyncRepo.upsertReviewCandidates(captureAny<Iterable<RemoteDeletedLocalAsset>>()),
).captured.single
as Iterable<RemoteDeletedLocalAsset>;
expect(reviewCandidates.map((item) => item.asset.id), ['local-only']);
});
test("uses review mode without moving assets to trash", () async {
trashSyncMode = TrashSyncMode.review;
final localAsset = LocalAssetStub.image1.copyWith(id: 'local-only', checksum: 'checksum-review', remoteId: null);
final candidates = [RemoteDeletedLocalAsset(asset: localAsset, remoteDeletedAt: DateTime(2025, 5, 1))];
when(
() => mockTrashSyncRepo.getRemoteTrashCandidates(any<Map<String, DateTime>>()),
).thenAnswer((_) async => candidates);
await sut.syncRemoteTrashState([(id: 'remote-1', deletedAt: DateTime(2025, 5, 1))]);
verify(() => mockTrashSyncRepo.upsertReviewCandidates(any())).called(1);
verifyNever(() => mockAssetMediaRepo.deleteAll(any()));
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAssets(any()));
});
test("records review candidates even when Android trash settings and permission are disabled", () async {
final localAsset = LocalAssetStub.image1.copyWith(id: 'local-only', checksum: 'checksum-disabled');
final candidates = [RemoteDeletedLocalAsset(asset: localAsset, remoteDeletedAt: DateTime(2025, 5, 1))];
when(
() => mockTrashSyncRepo.getRemoteTrashCandidates(any<Map<String, DateTime>>()),
).thenAnswer((_) async => candidates);
await sut.syncRemoteTrashState([(id: 'remote-1', deletedAt: DateTime(2025, 5, 1))]);
verify(() => mockTrashSyncRepo.upsertReviewCandidates(any())).called(1);
verifyNever(() => mockPermissionRepo.hasManageMediaPermission());
verifyNever(() => mockAssetMediaRepo.deleteAll(any()));
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAssets(any()));
});
test("cleans stale review entries for restored remote assets without media permission", () async {
when(() => mockTrashSyncRepo.deleteOutdated(any())).thenAnswer((invocation) async {
final remoteIds = invocation.positionalArguments.first as Iterable<String>;
expect(remoteIds.toList(), ['remote-1']);
return 0;
});
await sut.syncRemoteTrashState([(id: 'remote-1', deletedAt: null)]);
verify(() => mockTrashSyncRepo.deleteOutdated(any())).called(1);
verifyNever(() => mockPermissionRepo.hasManageMediaPermission());
verifyNever(() => mockAssetMediaRepo.restoreAssetsFromTrash(any()));
});
test("restores trashed local assets when matching remote assets leave the trash", () async {
trashSyncMode = TrashSyncMode.autoSync;
hasManageMediaPermission = true;
final trashedAssets = [
LocalAssetStub.image1.copyWith(id: 'trashed-1', checksum: 'checksum-trash', remoteId: 'remote-1'),
];
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => trashedAssets);
when(() => mockAssetMediaRepo.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
final requestedAssets = invocation.positionalArguments.first as Iterable<LocalAsset>;
expect(requestedAssets, orderedEquals(trashedAssets));
return ['trashed-1'];
});
await sut.syncRemoteTrashState([(id: 'remote-1', deletedAt: null)]);
verify(() => mockTrashedLocalAssetRepo.applyRestoredAssets(['trashed-1'])).called(1);
});
test("does not auto restore on iOS review mode", () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
trashSyncMode = TrashSyncMode.review;
await sut.syncRemoteTrashState([(id: 'remote-1', deletedAt: null)]);
verify(() => mockTrashSyncRepo.deleteOutdated(any())).called(1);
verifyNever(() => mockTrashedLocalAssetRepo.getToRestore());
verifyNever(() => mockAssetMediaRepo.restoreAssetsFromTrash(any()));
});
test("requests local deletion candidates by remote ids for permanent remote delete events", () async {
when(() => mockTrashSyncRepo.getRemoteTrashCandidates(any<Map<String, DateTime>>())).thenAnswer((
invocation,
) async {
final lookup = invocation.positionalArguments.first as Map<String, DateTime>;
expect(lookup.keys.toSet(), equals({'remote-asset'}));
return [];
});
await sut.applyRemoteRemovalToLocal({'remote-asset': DateTime(2025, 6, 1)});
verify(() => mockTrashSyncRepo.getRemoteTrashCandidates(any<Map<String, DateTime>>())).called(1);
verifyNever(() => mockAssetMediaRepo.deleteAll(any()));
});
});
}

View File

@ -30,6 +30,7 @@ import 'schema_v23.dart' as v23;
import 'schema_v24.dart' as v24;
import 'schema_v25.dart' as v25;
import 'schema_v26.dart' as v26;
import 'schema_v27.dart' as v27;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@ -87,6 +88,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v25.DatabaseAtV25(db);
case 26:
return v26.DatabaseAtV26(db);
case 27:
return v27.DatabaseAtV27(db);
default:
throw MissingSchemaException(version, versions);
}
@ -119,5 +122,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
24,
25,
26,
27,
];
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,138 @@
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:intl/date_symbol_data_local.dart';
void main() {
late Drift db;
late DriftTimelineRepository repository;
setUpAll(() async {
await initializeDateFormatting('en');
});
setUp(() {
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
repository = DriftTimelineRepository(db);
});
tearDown(() async {
await db.close();
});
Future<void> insertLocalAsset({required String id, required String checksum, required DateTime createdAt}) {
return db
.into(db.localAssetEntity)
.insert(
LocalAssetEntityCompanion.insert(
id: id,
checksum: Value(checksum),
name: '$id.jpg',
type: AssetType.image,
createdAt: Value(createdAt),
updatedAt: Value(createdAt),
),
);
}
Future<void> insertLocalAlbum({required String id, BackupSelection backupSelection = BackupSelection.selected}) {
return db
.into(db.localAlbumEntity)
.insert(LocalAlbumEntityCompanion.insert(id: id, name: id, backupSelection: backupSelection));
}
Future<void> insertLocalAlbumAsset({required String albumId, required String assetId}) {
return db
.into(db.localAlbumAssetEntity)
.insert(LocalAlbumAssetEntityCompanion.insert(albumId: albumId, assetId: assetId));
}
Future<void> insertTrashSync(String checksum) {
return db
.into(db.trashSyncEntity)
.insert(TrashSyncEntityCompanion.insert(checksum: checksum, remoteDeletedAt: DateTime(2025, 1, 10, 12)));
}
Future<void> insertTrashedLocalAsset(String checksum, {String? id}) {
final now = DateTime(2025, 1, 10, 12);
return db
.into(db.trashedLocalAssetEntity)
.insert(
TrashedLocalAssetEntityCompanion.insert(
id: id ?? 'trashed-$checksum',
albumId: 'album-$checksum',
checksum: Value(checksum),
name: 'trashed-$checksum.jpg',
type: AssetType.image,
createdAt: Value(now),
updatedAt: Value(now),
source: TrashOrigin.localSync,
),
);
}
group('toTrashSyncReview', () {
test('uses local assets and returns one pending entry per checksum', () async {
await insertLocalAlbum(id: 'selected-album');
await insertLocalAlbum(id: 'unselected-album', backupSelection: BackupSelection.none);
await insertTrashSync('duplicate-checksum');
await insertTrashSync('single-checksum');
await insertTrashSync('missing-local-checksum');
await insertTrashSync('unselected-checksum');
await insertLocalAsset(id: 'a-duplicate', checksum: 'duplicate-checksum', createdAt: DateTime(2025, 1, 1, 12));
await insertLocalAsset(
id: 'z-newer-duplicate',
checksum: 'duplicate-checksum',
createdAt: DateTime(2025, 1, 2, 12),
);
await insertLocalAsset(id: 'single', checksum: 'single-checksum', createdAt: DateTime(2025, 1, 3, 12));
await insertLocalAsset(id: 'unselected', checksum: 'unselected-checksum', createdAt: DateTime(2025, 1, 5, 12));
await insertLocalAlbumAsset(albumId: 'selected-album', assetId: 'a-duplicate');
await insertLocalAlbumAsset(albumId: 'selected-album', assetId: 'z-newer-duplicate');
await insertLocalAlbumAsset(albumId: 'selected-album', assetId: 'single');
await insertLocalAlbumAsset(albumId: 'unselected-album', assetId: 'unselected');
final query = repository.toTrashSyncReview(GroupAssetsBy.day);
final assets = await query.assetSource(0, 10);
final localIds = assets.whereType<LocalAsset>().map((asset) => asset.id).toList();
expect(localIds, ['single', 'a-duplicate']);
expect(localIds, isNot(contains('z-newer-duplicate')));
expect(localIds, isNot(contains('unselected')));
final buckets = await query.bucketSource().first;
expect(buckets.map((bucket) => bucket.assetCount), [1, 1]);
});
test('does not hide an actionable duplicate when another copy with the same checksum is in local trash', () async {
await insertLocalAlbum(id: 'selected-album');
await insertTrashSync('shared-checksum');
await insertLocalAsset(id: 'alive-copy', checksum: 'shared-checksum', createdAt: DateTime(2025, 1, 1, 12));
await insertLocalAlbumAsset(albumId: 'selected-album', assetId: 'alive-copy');
await insertTrashedLocalAsset('shared-checksum', id: 'trashed-copy');
final query = repository.toTrashSyncReview(GroupAssetsBy.day);
final assets = await query.assetSource(0, 10);
final localIds = assets.whereType<LocalAsset>().map((asset) => asset.id).toList();
expect(localIds, ['alive-copy']);
});
});
}

View File

@ -0,0 +1,537 @@
import 'package:drift/drift.dart' hide isNotNull, isNull;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset/remote_deleted_local_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import '../../fixtures/asset.stub.dart';
void main() {
late Drift db;
late DriftTrashSyncRepository repository;
setUp(() async {
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
repository = DriftTrashSyncRepository(db);
await db
.into(db.userEntity)
.insert(UserEntityCompanion.insert(id: 'user-1', name: 'user-1', email: 'user-1@example.com'));
});
tearDown(() async {
await db.close();
});
Future<void> insertTrashSync({
required String checksum,
bool? isSyncApproved,
required DateTime remoteDeletedAt,
}) async {
await db
.into(db.trashSyncEntity)
.insert(
TrashSyncEntityCompanion.insert(
checksum: checksum,
isSyncApproved: Value(isSyncApproved),
remoteDeletedAt: remoteDeletedAt,
),
);
}
Future<void> insertRemoteAsset({required String checksum, DateTime? deletedAt}) async {
final now = DateTime(2025, 1, 1);
await db
.into(db.remoteAssetEntity)
.insert(
RemoteAssetEntityCompanion.insert(
id: 'remote-$checksum',
checksum: checksum,
name: 'remote-$checksum.jpg',
ownerId: 'user-1',
type: AssetType.image,
createdAt: Value(now),
updatedAt: Value(now),
visibility: AssetVisibility.timeline,
deletedAt: Value(deletedAt),
),
);
}
Future<void> insertLocalAlbum({
required String id,
BackupSelection backupSelection = BackupSelection.selected,
}) async {
await db
.into(db.localAlbumEntity)
.insert(LocalAlbumEntityCompanion.insert(id: id, name: id, backupSelection: backupSelection));
}
Future<void> insertLocalAlbumAsset({required String albumId, required String assetId}) async {
await db
.into(db.localAlbumAssetEntity)
.insert(LocalAlbumAssetEntityCompanion.insert(albumId: albumId, assetId: assetId));
}
Future<void> insertLocalAsset({required String checksum, String? id}) async {
final now = DateTime(2025, 1, 1);
await db
.into(db.localAssetEntity)
.insert(
LocalAssetEntityCompanion.insert(
id: id ?? 'local-$checksum',
checksum: Value(checksum),
name: 'local-$checksum.jpg',
type: AssetType.image,
createdAt: Value(now),
updatedAt: Value(now),
),
);
}
Future<void> insertTrashedLocalAsset({required String checksum}) async {
final now = DateTime(2025, 1, 1);
await db
.into(db.trashedLocalAssetEntity)
.insert(
TrashedLocalAssetEntityCompanion.insert(
id: 'trashed-$checksum',
albumId: 'album-$checksum',
name: 'trashed-$checksum.jpg',
type: AssetType.image,
checksum: Value(checksum),
createdAt: Value(now),
updatedAt: Value(now),
source: TrashOrigin.localSync,
),
);
}
group('getRemoteTrashCandidates', () {
test('returns local assets matched by remote id without requiring selected backup albums', () async {
final remoteDeletedAt = DateTime(2025, 6, 1);
await insertRemoteAsset(checksum: 'matched-checksum', deletedAt: remoteDeletedAt);
await insertRemoteAsset(checksum: 'remote-only-checksum', deletedAt: DateTime(2025, 6, 2));
await insertLocalAsset(checksum: 'matched-checksum');
await insertLocalAlbum(id: 'selected-album');
await insertLocalAlbum(id: 'unselected-album', backupSelection: BackupSelection.none);
await insertLocalAlbumAsset(albumId: 'selected-album', assetId: 'local-matched-checksum');
await insertLocalAlbumAsset(albumId: 'unselected-album', assetId: 'local-matched-checksum');
final result = await repository.getRemoteTrashCandidates({
'remote-matched-checksum': remoteDeletedAt,
'remote-remote-only-checksum': DateTime(2025, 6, 2),
});
expect(result, hasLength(1));
expect(result.single.asset.id, 'local-matched-checksum');
expect(result.single.asset.remoteId, 'remote-matched-checksum');
expect(result.single.remoteDeletedAt, remoteDeletedAt);
});
test('excludes assets with accepted or rejected trash sync decisions', () async {
final remoteDeletedAt = DateTime(2025, 6, 1);
await insertLocalAlbum(id: 'selected-album');
await insertRemoteAsset(checksum: 'pending-checksum', deletedAt: remoteDeletedAt);
await insertRemoteAsset(checksum: 'rejected-checksum', deletedAt: remoteDeletedAt);
await insertRemoteAsset(checksum: 'approved-checksum', deletedAt: remoteDeletedAt);
await insertLocalAsset(checksum: 'pending-checksum');
await insertLocalAsset(checksum: 'rejected-checksum');
await insertLocalAsset(checksum: 'approved-checksum');
await insertLocalAlbumAsset(albumId: 'selected-album', assetId: 'local-pending-checksum');
await insertLocalAlbumAsset(albumId: 'selected-album', assetId: 'local-rejected-checksum');
await insertLocalAlbumAsset(albumId: 'selected-album', assetId: 'local-approved-checksum');
await insertTrashSync(checksum: 'pending-checksum', isSyncApproved: null, remoteDeletedAt: remoteDeletedAt);
await insertTrashSync(checksum: 'rejected-checksum', isSyncApproved: false, remoteDeletedAt: remoteDeletedAt);
await insertTrashSync(checksum: 'approved-checksum', isSyncApproved: true, remoteDeletedAt: remoteDeletedAt);
final result = await repository.getRemoteTrashCandidates({
'remote-pending-checksum': remoteDeletedAt,
'remote-rejected-checksum': remoteDeletedAt,
'remote-approved-checksum': remoteDeletedAt,
});
expect(result.map((item) => item.asset.id), ['local-pending-checksum']);
});
});
group('getSelectedBackupRemoteTrashMoveCandidates', () {
test('returns only candidates from selected backup albums', () async {
await insertLocalAsset(checksum: 'selected-checksum');
await insertLocalAsset(checksum: 'unselected-checksum');
await insertLocalAlbum(id: 'selected-album');
await insertLocalAlbum(id: 'unselected-album', backupSelection: BackupSelection.none);
await insertLocalAlbumAsset(albumId: 'selected-album', assetId: 'local-selected-checksum');
await insertLocalAlbumAsset(albumId: 'unselected-album', assetId: 'local-selected-checksum');
await insertLocalAlbumAsset(albumId: 'unselected-album', assetId: 'local-unselected-checksum');
final selectedCandidate = RemoteDeletedLocalAsset(
asset: LocalAssetStub.image1.copyWith(id: 'local-selected-checksum', checksum: 'selected-checksum'),
remoteDeletedAt: DateTime(2025, 6, 1),
);
final unselectedCandidate = RemoteDeletedLocalAsset(
asset: LocalAssetStub.image2.copyWith(id: 'local-unselected-checksum', checksum: 'unselected-checksum'),
remoteDeletedAt: DateTime(2025, 6, 2),
);
final result = await repository.getSelectedBackupRemoteTrashMoveCandidates([
selectedCandidate,
unselectedCandidate,
]);
expect(result, [(albumId: 'selected-album', candidate: selectedCandidate)]);
});
test('returns one candidate per selected album for the same asset', () async {
await insertLocalAsset(checksum: 'selected-checksum');
await insertLocalAlbum(id: 'selected-1');
await insertLocalAlbum(id: 'selected-2');
await insertLocalAlbumAsset(albumId: 'selected-1', assetId: 'local-selected-checksum');
await insertLocalAlbumAsset(albumId: 'selected-2', assetId: 'local-selected-checksum');
final candidate = RemoteDeletedLocalAsset(
asset: LocalAssetStub.image1.copyWith(id: 'local-selected-checksum', checksum: 'selected-checksum'),
remoteDeletedAt: DateTime(2025, 6, 1),
);
final result = await repository.getSelectedBackupRemoteTrashMoveCandidates([candidate]);
expect(result.map((item) => item.candidate), [candidate, candidate]);
expect(result.map((item) => item.albumId), unorderedEquals(['selected-1', 'selected-2']));
});
});
group('getTrashSyncMoveCandidates', () {
test('returns local assets from selected backup albums matched by trash sync checksum', () async {
final remoteDeletedAt = DateTime(2025, 6, 1);
await insertLocalAsset(checksum: 'checksum-1');
await insertLocalAlbum(id: 'selected-album');
await insertLocalAlbum(id: 'unselected-album', backupSelection: BackupSelection.none);
await insertLocalAlbumAsset(albumId: 'selected-album', assetId: 'local-checksum-1');
await insertLocalAlbumAsset(albumId: 'unselected-album', assetId: 'local-checksum-1');
await insertTrashSync(checksum: 'checksum-1', isSyncApproved: null, remoteDeletedAt: remoteDeletedAt);
final result = await repository.getTrashSyncMoveCandidates(['checksum-1']);
expect(result, hasLength(1));
expect(result.single.albumId, 'selected-album');
expect(result.single.candidate.asset.id, 'local-checksum-1');
expect(result.single.candidate.asset.remoteId, isNull);
expect(result.single.candidate.remoteDeletedAt, remoteDeletedAt);
});
test('excludes non-pending trash sync decisions', () async {
final remoteDeletedAt = DateTime(2025, 6, 1);
await insertLocalAlbum(id: 'selected-album');
await insertLocalAsset(checksum: 'pending-checksum');
await insertLocalAsset(checksum: 'rejected-checksum');
await insertLocalAsset(checksum: 'approved-checksum');
await insertLocalAlbumAsset(albumId: 'selected-album', assetId: 'local-pending-checksum');
await insertLocalAlbumAsset(albumId: 'selected-album', assetId: 'local-rejected-checksum');
await insertLocalAlbumAsset(albumId: 'selected-album', assetId: 'local-approved-checksum');
await insertTrashSync(checksum: 'pending-checksum', isSyncApproved: null, remoteDeletedAt: remoteDeletedAt);
await insertTrashSync(checksum: 'rejected-checksum', isSyncApproved: false, remoteDeletedAt: remoteDeletedAt);
await insertTrashSync(checksum: 'approved-checksum', isSyncApproved: true, remoteDeletedAt: remoteDeletedAt);
final result = await repository.getTrashSyncMoveCandidates([
'pending-checksum',
'rejected-checksum',
'approved-checksum',
]);
expect(result.map((item) => item.albumId), ['selected-album']);
expect(result.map((item) => item.candidate.asset.id), ['local-pending-checksum']);
});
});
group('upsertReviewCandidates', () {
test('inserts new entries and updates pending entries when newer', () async {
final oldTime = DateTime(2025, 1, 1);
final newTime = DateTime(2025, 1, 2);
await insertTrashSync(checksum: 'approved', isSyncApproved: true, remoteDeletedAt: oldTime);
await insertTrashSync(checksum: 'pending', isSyncApproved: null, remoteDeletedAt: oldTime);
await insertTrashSync(checksum: 'rejected', isSyncApproved: false, remoteDeletedAt: oldTime);
await insertTrashSync(checksum: 'rejected-newer', isSyncApproved: false, remoteDeletedAt: newTime);
final items = [
RemoteDeletedLocalAsset(
asset: LocalAssetStub.image1.copyWith(checksum: 'new'),
remoteDeletedAt: newTime,
),
RemoteDeletedLocalAsset(
asset: LocalAssetStub.image1.copyWith(checksum: 'pending'),
remoteDeletedAt: newTime,
),
RemoteDeletedLocalAsset(
asset: LocalAssetStub.image1.copyWith(checksum: 'rejected'),
remoteDeletedAt: newTime,
),
RemoteDeletedLocalAsset(
asset: LocalAssetStub.image1.copyWith(checksum: 'approved'),
remoteDeletedAt: newTime,
),
RemoteDeletedLocalAsset(
asset: LocalAssetStub.image1.copyWith(checksum: 'rejected-newer'),
remoteDeletedAt: oldTime,
),
];
await repository.upsertReviewCandidates(items);
final rows = await db.select(db.trashSyncEntity).get();
final byChecksum = {for (final row in rows) row.checksum: row};
expect(byChecksum['new'], isNotNull);
expect(byChecksum['new']!.isSyncApproved, isNull);
expect(byChecksum['new']?.remoteDeletedAt, newTime);
expect(byChecksum['pending'], isNotNull);
expect(byChecksum['pending']!.isSyncApproved, isNull);
expect(byChecksum['pending']?.remoteDeletedAt, newTime);
expect(byChecksum['rejected'], isNotNull);
expect(byChecksum['rejected']!.isSyncApproved, isFalse);
expect(byChecksum['rejected']?.remoteDeletedAt, oldTime);
expect(byChecksum['approved']?.isSyncApproved, isTrue);
expect(byChecksum['approved']?.remoteDeletedAt, oldTime);
expect(byChecksum['rejected-newer']?.isSyncApproved, isFalse);
expect(byChecksum['rejected-newer']?.remoteDeletedAt, newTime);
});
});
group('watch review approval state', () {
test('counts only actionable pending approvals from selected backup albums', () async {
final now = DateTime(2025, 1, 1);
await insertLocalAlbum(id: 'selected-album');
await insertLocalAlbum(id: 'unselected-album', backupSelection: BackupSelection.none);
await insertTrashSync(checksum: 'pending-selected', isSyncApproved: null, remoteDeletedAt: now);
await insertLocalAsset(checksum: 'pending-selected');
await insertLocalAlbumAsset(albumId: 'selected-album', assetId: 'local-pending-selected');
await insertTrashSync(checksum: 'pending-unselected', isSyncApproved: null, remoteDeletedAt: now);
await insertLocalAsset(checksum: 'pending-unselected');
await insertLocalAlbumAsset(albumId: 'unselected-album', assetId: 'local-pending-unselected');
await insertTrashSync(checksum: 'pending-no-album', isSyncApproved: null, remoteDeletedAt: now);
await insertLocalAsset(checksum: 'pending-no-album');
await insertTrashSync(checksum: 'pending-local-trash', isSyncApproved: null, remoteDeletedAt: now);
await insertLocalAsset(checksum: 'pending-local-trash');
await insertLocalAlbumAsset(albumId: 'selected-album', assetId: 'local-pending-local-trash');
await insertTrashedLocalAsset(checksum: 'pending-local-trash');
await insertTrashSync(checksum: 'rejected-selected', isSyncApproved: false, remoteDeletedAt: now);
await insertLocalAsset(checksum: 'rejected-selected');
await insertLocalAlbumAsset(albumId: 'selected-album', assetId: 'local-rejected-selected');
await insertTrashSync(checksum: 'approved-selected', isSyncApproved: true, remoteDeletedAt: now);
await insertLocalAsset(checksum: 'approved-selected');
await insertLocalAlbumAsset(albumId: 'selected-album', assetId: 'local-approved-selected');
await expectLater(repository.watchPendingApprovalAssetCount(), emits(2));
await expectLater(repository.watchIsAssetApprovalPending('pending-selected'), emits(true));
await expectLater(repository.watchIsAssetApprovalPending('pending-unselected'), emits(false));
await expectLater(repository.watchIsAssetApprovalPending('pending-no-album'), emits(false));
await expectLater(repository.watchIsAssetApprovalPending('pending-local-trash'), emits(true));
await expectLater(repository.watchIsAssetApprovalPending('rejected-selected'), emits(false));
await expectLater(repository.watchIsAssetApprovalPending('approved-selected'), emits(false));
});
});
group('deleteOutdated', () {
test('removes entries for matched alive remote ids', () async {
final now = DateTime(2025, 1, 1);
await insertRemoteAsset(checksum: 'alive-matched', deletedAt: null);
await insertRemoteAsset(checksum: 'alive-rejected', deletedAt: null);
await insertRemoteAsset(checksum: 'alive-approved', deletedAt: null);
await insertRemoteAsset(checksum: 'alive-not-requested', deletedAt: null);
await insertRemoteAsset(checksum: 'deleted-matched', deletedAt: now);
await insertTrashSync(checksum: 'alive-matched', isSyncApproved: null, remoteDeletedAt: now);
await insertTrashSync(checksum: 'alive-rejected', isSyncApproved: false, remoteDeletedAt: now);
await insertTrashSync(checksum: 'alive-approved', isSyncApproved: true, remoteDeletedAt: now);
await insertTrashSync(checksum: 'alive-not-requested', isSyncApproved: null, remoteDeletedAt: now);
await insertTrashSync(checksum: 'deleted-matched', isSyncApproved: null, remoteDeletedAt: now);
await insertTrashSync(checksum: 'missing-remote', isSyncApproved: null, remoteDeletedAt: now);
final deleted = await repository.deleteOutdated([
'remote-alive-matched',
'remote-alive-rejected',
'remote-alive-approved',
'remote-deleted-matched',
'remote-missing-remote',
]);
expect(deleted, 3);
final remaining = await db.select(db.trashSyncEntity).get();
final remainingChecksums = remaining.map((row) => row.checksum).toSet();
expect(remainingChecksums, containsAll(['alive-not-requested', 'deleted-matched', 'missing-remote']));
expect(remainingChecksums, isNot(contains('alive-matched')));
expect(remainingChecksums, isNot(contains('alive-rejected')));
expect(remainingChecksums, isNot(contains('alive-approved')));
});
test('removes entries across remote id chunks', () async {
final now = DateTime(2025, 1, 1);
final count = kDriftMaxChunk + 1;
for (var i = 0; i < count; i++) {
final checksum = 'alive-$i';
await insertRemoteAsset(checksum: checksum, deletedAt: null);
await insertTrashSync(checksum: checksum, isSyncApproved: null, remoteDeletedAt: now);
}
final deleted = await repository.deleteOutdated(List.generate(count, (i) => 'remote-alive-$i'));
expect(deleted, count);
expect(await db.managers.trashSyncEntity.count(), 0);
});
});
group('deleteResolved', () {
test('removes pending and rejected entries for matched checksums', () async {
final now = DateTime(2025, 1, 1);
await insertTrashSync(checksum: 'pending-matched', isSyncApproved: null, remoteDeletedAt: now);
await insertTrashSync(checksum: 'rejected-matched', isSyncApproved: false, remoteDeletedAt: now);
await insertTrashSync(checksum: 'approved-matched', isSyncApproved: true, remoteDeletedAt: now);
await insertTrashSync(checksum: 'pending-not-requested', isSyncApproved: null, remoteDeletedAt: now);
final deleted = await repository.deleteResolved(['pending-matched', 'rejected-matched', 'approved-matched']);
expect(deleted, 2);
final remaining = await db.select(db.trashSyncEntity).get();
final remainingChecksums = remaining.map((row) => row.checksum).toSet();
expect(remainingChecksums, containsAll(['approved-matched', 'pending-not-requested']));
expect(remainingChecksums, isNot(contains('pending-matched')));
expect(remainingChecksums, isNot(contains('rejected-matched')));
});
test('removes entries across checksum chunks', () async {
final now = DateTime(2025, 1, 1);
final count = kDriftMaxChunk + 1;
for (var i = 0; i < count; i++) {
await insertTrashSync(checksum: 'pending-$i', isSyncApproved: null, remoteDeletedAt: now);
}
final deleted = await repository.deleteResolved(List.generate(count, (i) => 'pending-$i'));
expect(deleted, count);
expect(await db.managers.trashSyncEntity.count(), 0);
});
});
group('cleanupLocalTrashSync', () {
test('removes pending and rejected entries with no live local asset', () async {
final now = DateTime(2025, 1, 1);
await insertTrashedLocalAsset(checksum: 'local-pending');
await insertTrashedLocalAsset(checksum: 'local-rejected');
await insertTrashedLocalAsset(checksum: 'local-approved');
await insertTrashedLocalAsset(checksum: 'live-duplicate');
await insertLocalAsset(checksum: 'live-duplicate');
await insertTrashSync(checksum: 'local-pending', isSyncApproved: null, remoteDeletedAt: now);
await insertTrashSync(checksum: 'local-rejected', isSyncApproved: false, remoteDeletedAt: now);
await insertTrashSync(checksum: 'local-approved', isSyncApproved: true, remoteDeletedAt: now);
await insertTrashSync(checksum: 'live-duplicate', isSyncApproved: null, remoteDeletedAt: now);
await insertTrashSync(checksum: 'not-local', isSyncApproved: null, remoteDeletedAt: now);
final deleted = await repository.cleanupLocalTrashSync();
expect(deleted, 3);
final remaining = await db.select(db.trashSyncEntity).get();
final remainingChecksums = remaining.map((row) => row.checksum).toSet();
expect(remainingChecksums, containsAll(['local-approved', 'live-duplicate']));
expect(remainingChecksums, isNot(contains('local-pending')));
expect(remainingChecksums, isNot(contains('local-rejected')));
expect(remainingChecksums, isNot(contains('not-local')));
});
test('removes stale entries', () async {
final now = DateTime(2025, 1, 1);
await insertRemoteAsset(checksum: 'alive-remote', deletedAt: null);
await insertRemoteAsset(checksum: 'alive-approved', deletedAt: null);
await insertLocalAsset(checksum: 'pending-keep');
await insertLocalAsset(checksum: 'reject-keep');
await insertTrashedLocalAsset(checksum: 'approve-keep');
await insertTrashedLocalAsset(checksum: 'local-trashed');
await insertTrashSync(checksum: 'alive-remote', isSyncApproved: null, remoteDeletedAt: now);
await insertTrashSync(checksum: 'alive-approved', isSyncApproved: true, remoteDeletedAt: now);
await insertTrashSync(checksum: 'local-trashed', isSyncApproved: false, remoteDeletedAt: now);
await insertTrashSync(checksum: 'pending-keep', isSyncApproved: null, remoteDeletedAt: now);
await insertTrashSync(checksum: 'pending-orphan', isSyncApproved: null, remoteDeletedAt: now);
await insertTrashSync(checksum: 'reject-orphan', isSyncApproved: false, remoteDeletedAt: now);
await insertTrashSync(checksum: 'reject-keep', isSyncApproved: false, remoteDeletedAt: now);
await insertTrashSync(checksum: 'approve-orphan', isSyncApproved: true, remoteDeletedAt: now);
await insertTrashSync(checksum: 'approve-keep', isSyncApproved: true, remoteDeletedAt: now);
final deleted = await repository.cleanupLocalTrashSync();
expect(deleted, 6);
final remaining = await db.select(db.trashSyncEntity).get();
final remainingChecksums = remaining.map((row) => row.checksum).toSet();
expect(remainingChecksums, containsAll(['pending-keep', 'reject-keep', 'approve-keep']));
expect(remainingChecksums, isNot(contains('alive-remote')));
expect(remainingChecksums, isNot(contains('alive-approved')));
expect(remainingChecksums, isNot(contains('local-trashed')));
expect(remainingChecksums, isNot(contains('pending-orphan')));
expect(remainingChecksums, isNot(contains('reject-orphan')));
expect(remainingChecksums, isNot(contains('approve-orphan')));
});
test('stale review cleanup is throttled', () async {
final firstRun = await repository.cleanupLocalTrashSync();
final secondRun = await repository.cleanupLocalTrashSync();
expect(firstRun, 0);
expect(secondRun, 0);
});
test('orphaned review cleanup is not throttled', () async {
final now = DateTime(2025, 1, 1);
await repository.cleanupLocalTrashSync();
await insertTrashSync(checksum: 'pending-orphan', isSyncApproved: null, remoteDeletedAt: now);
await insertTrashSync(checksum: 'rejected-orphan', isSyncApproved: false, remoteDeletedAt: now);
await insertTrashSync(checksum: 'approved-orphan', isSyncApproved: true, remoteDeletedAt: now);
final deleted = await repository.cleanupLocalTrashSync();
expect(deleted, 2);
final remaining = await db.select(db.trashSyncEntity).get();
final remainingChecksums = remaining.map((row) => row.checksum).toSet();
expect(remainingChecksums, {'approved-orphan'});
});
});
}

View File

@ -10,6 +10,7 @@ import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
@ -36,6 +37,8 @@ class MockRemoteAssetRepository extends Mock implements RemoteAssetRepository {}
class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {}
class MockTrashSyncRepository extends Mock implements DriftTrashSyncRepository {}
class MockStorageRepository extends Mock implements StorageRepository {}
class MockDriftBackupRepository extends Mock implements DriftBackupRepository {}

View File

@ -2,15 +2,18 @@ import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/asset/remote_deleted_local_asset.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/repositories/download.repository.dart';
import 'package:immich_mobile/services/action.service.dart';
import 'package:logging/logging.dart';
import 'package:mocktail/mocktail.dart';
import '../fixtures/asset.stub.dart';
import '../infrastructure/repository.mock.dart';
import '../repository.mocks.dart';
@ -25,6 +28,7 @@ void main() {
late MockDriftAlbumApiRepository albumApiRepository;
late MockRemoteAlbumRepository remoteAlbumRepository;
late MockTrashedLocalAssetRepository trashedLocalAssetRepository;
late MockTrashSyncRepository trashSyncRepository;
late MockAssetMediaRepository assetMediaRepository;
late MockDownloadRepository downloadRepository;
late MockTagService tagService;
@ -32,6 +36,8 @@ void main() {
late Drift db;
setUpAll(() async {
registerFallbackValue(LocalAssetStub.image1);
registerFallbackValue(<String>[]);
TestWidgetsFlutterBinding.ensureInitialized();
debugDefaultTargetPlatformOverride = TargetPlatform.android;
@ -52,6 +58,7 @@ void main() {
albumApiRepository = MockDriftAlbumApiRepository();
remoteAlbumRepository = MockRemoteAlbumRepository();
trashedLocalAssetRepository = MockTrashedLocalAssetRepository();
trashSyncRepository = MockTrashSyncRepository();
assetMediaRepository = MockAssetMediaRepository();
downloadRepository = MockDownloadRepository();
tagService = MockTagService();
@ -63,10 +70,21 @@ void main() {
albumApiRepository,
remoteAlbumRepository,
trashedLocalAssetRepository,
trashSyncRepository,
assetMediaRepository,
downloadRepository,
tagService,
Logger('ActionServiceTest'),
);
when(() => trashSyncRepository.getTrashSyncMoveCandidates(any())).thenAnswer((_) async => []);
when(() => localAssetRepository.delete(any())).thenAnswer((_) async {});
when(() => trashedLocalAssetRepository.trashLocalAssets(any())).thenAnswer((_) async {});
when(() => trashSyncRepository.updateApproves(any(), any())).thenAnswer((_) async {});
when(() => trashSyncRepository.transaction<void>(any())).thenAnswer((invocation) {
final callback = invocation.positionalArguments.first as Future<void> Function();
return callback();
});
});
tearDown(() async {
@ -74,8 +92,7 @@ void main() {
});
group('ActionService.deleteLocal', () {
test('routes deleted ids to trashed repository when Android trash handling is enabled', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
test('routes deleted ids to trashed repository on Android', () async {
const ids = ['a', 'b'];
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids);
@ -89,8 +106,9 @@ void main() {
verifyNever(() => localAssetRepository.delete(any()));
});
test('deletes locally when Android trash handling is disabled', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, false);
test('deletes local rows directly on iOS', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
const ids = ['c'];
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids);
@ -105,7 +123,6 @@ void main() {
});
test('short-circuits when nothing was deleted', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
const ids = ['x'];
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => <String>[]);
@ -118,4 +135,192 @@ void main() {
verifyNever(() => localAssetRepository.delete(any()));
});
});
group('ActionService.resolveRemoteTrash', () {
test('updates approvals and returns requested count when disallowed', () async {
when(() => trashSyncRepository.updateApproves(any(), false)).thenAnswer((_) async {});
final result = await sut.resolveRemoteTrash(['checksum-1'], isSyncApproved: false);
expect(result, (displayCount: 1, success: true));
verify(() => trashSyncRepository.updateApproves(any(), false)).called(1);
verifyNever(() => trashSyncRepository.getTrashSyncMoveCandidates(any()));
verifyNever(() => assetMediaRepository.deleteAll(any()));
});
test('returns 0 when no local assets match', () async {
when(() => trashSyncRepository.getTrashSyncMoveCandidates(any())).thenAnswer((_) async => []);
when(() => trashSyncRepository.updateApproves(any(), true)).thenAnswer((_) async {});
final result = await sut.resolveRemoteTrash(['checksum-1'], isSyncApproved: true);
expect(result, (displayCount: 1, success: true));
verify(() => trashSyncRepository.getTrashSyncMoveCandidates(any())).called(1);
verify(() => trashSyncRepository.updateApproves(any(), true)).called(1);
verifyNever(() => assetMediaRepository.deleteAll(any()));
});
test('keeps review pending when media plugin deletes no local files', () async {
final localAsset = LocalAssetStub.image1.copyWith(checksum: 'checksum-1');
final remoteDeleted = RemoteDeletedLocalAsset(asset: localAsset, remoteDeletedAt: DateTime(2024, 1, 1));
when(
() => trashSyncRepository.getTrashSyncMoveCandidates(any()),
).thenAnswer((_) async => [(albumId: 'album-1', candidate: remoteDeleted)]);
when(() => assetMediaRepository.deleteAll(any())).thenAnswer((_) async => []);
final result = await sut.resolveRemoteTrash(['checksum-1'], isSyncApproved: true);
expect(result, (displayCount: 0, success: false));
verify(() => assetMediaRepository.deleteAll([localAsset.id])).called(1);
verifyNever(() => trashSyncRepository.updateApproves(any(), true));
});
test('moves files to trash through media plugin and updates approvals on success', () async {
final localAsset = LocalAssetStub.image1.copyWith(checksum: 'checksum-1', remoteId: 'remote-1');
final remoteDeleted = RemoteDeletedLocalAsset(asset: localAsset, remoteDeletedAt: DateTime(2024, 1, 1));
when(
() => trashSyncRepository.getTrashSyncMoveCandidates(any()),
).thenAnswer((_) async => [(albumId: 'album-1', candidate: remoteDeleted)]);
when(() => assetMediaRepository.deleteAll(any())).thenAnswer((_) async => [localAsset.id]);
when(() => trashSyncRepository.updateApproves(any(), true)).thenAnswer((_) async {});
final result = await sut.resolveRemoteTrash(['checksum-1'], isSyncApproved: true);
expect(result, (displayCount: 1, success: true));
verify(() => assetMediaRepository.deleteAll([localAsset.id])).called(1);
verify(() => trashSyncRepository.updateApproves(any(), true)).called(1);
});
test('does not update approvals when media plugin delete fails', () async {
final localAsset = LocalAssetStub.image1.copyWith(checksum: 'checksum-1');
final remoteDeleted = RemoteDeletedLocalAsset(asset: localAsset, remoteDeletedAt: DateTime(2024, 1, 1));
when(
() => trashSyncRepository.getTrashSyncMoveCandidates(any()),
).thenAnswer((_) async => [(albumId: 'album-1', candidate: remoteDeleted)]);
when(() => assetMediaRepository.deleteAll(any())).thenAnswer((_) async => []);
final result = await sut.resolveRemoteTrash(['checksum-1'], isSyncApproved: true);
expect(result, (displayCount: 0, success: false));
verify(() => assetMediaRepository.deleteAll([localAsset.id])).called(1);
verifyNever(() => trashSyncRepository.updateApproves(any(), true));
});
test('updates approvals and syncs trash after media plugin delete', () async {
final localAsset = LocalAssetStub.image1.copyWith(checksum: 'checksum-1', remoteId: 'remote-1');
final remoteDeleted = RemoteDeletedLocalAsset(asset: localAsset, remoteDeletedAt: DateTime(2024, 1, 1));
when(
() => trashSyncRepository.getTrashSyncMoveCandidates(any()),
).thenAnswer((_) async => [(albumId: 'album-1', candidate: remoteDeleted)]);
when(() => assetMediaRepository.deleteAll(any())).thenAnswer((_) async => [localAsset.id]);
final result = await sut.resolveRemoteTrash(['checksum-1'], isSyncApproved: true);
expect(result, (displayCount: 1, success: true));
verifyInOrder([
() => assetMediaRepository.deleteAll([localAsset.id]),
() => trashedLocalAssetRepository.trashLocalAssets(any()),
() => trashSyncRepository.updateApproves(any(), true),
]);
});
test('updates approvals and deletes local rows on iOS without writing local trash state', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
final localAsset = LocalAssetStub.image1.copyWith(checksum: 'checksum-1', remoteId: 'remote-1');
final remoteDeleted = RemoteDeletedLocalAsset(asset: localAsset, remoteDeletedAt: DateTime(2024, 1, 1));
when(
() => trashSyncRepository.getTrashSyncMoveCandidates(any()),
).thenAnswer((_) async => [(albumId: 'album-1', candidate: remoteDeleted)]);
when(() => assetMediaRepository.deleteAll(any())).thenAnswer((_) async => [localAsset.id]);
final result = await sut.resolveRemoteTrash(['checksum-1'], isSyncApproved: true);
expect(result, (displayCount: 1, success: true));
final deletedLocalIds =
verify(() => localAssetRepository.delete(captureAny())).captured.single as Iterable<String>;
expect(deletedLocalIds, [localAsset.id]);
verifyNever(() => trashedLocalAssetRepository.trashLocalAssets(any()));
verify(() => trashSyncRepository.updateApproves(any(), true)).called(1);
});
test('moves trash sync candidates preserving remote deletion dates', () async {
final asset1 = LocalAssetStub.image1.copyWith(checksum: 'checksum-1', remoteId: 'remote-1');
final asset2 = LocalAssetStub.image1.copyWith(checksum: 'checksum-2', remoteId: 'remote-2');
final deletedAt1 = DateTime(2024, 1, 1);
final deletedAt2 = DateTime(2024, 2, 2);
final remoteDeleted = [
RemoteDeletedLocalAsset(asset: asset1, remoteDeletedAt: deletedAt1),
RemoteDeletedLocalAsset(asset: asset2, remoteDeletedAt: deletedAt2),
];
when(
() => trashSyncRepository.getTrashSyncMoveCandidates(any()),
).thenAnswer((_) async => remoteDeleted.map((candidate) => (albumId: 'album-1', candidate: candidate)).toList());
when(() => assetMediaRepository.deleteAll(any())).thenAnswer((_) async => [asset1.id, asset2.id]);
final result = await sut.resolveRemoteTrash(['checksum-1', 'checksum-2'], isSyncApproved: true);
expect(result, (displayCount: 2, success: true));
final captured =
verify(() => trashedLocalAssetRepository.trashLocalAssets(captureAny())).captured.single
as Iterable<RemoteTrashMoveCandidate>;
expect(captured.map((item) => item.albumId), ['album-1', 'album-1']);
expect(captured.map((item) => item.candidate.remoteDeletedAt), [deletedAt1, deletedAt2]);
});
test('updates only deleted assets on partial media plugin success', () async {
final asset1 = LocalAssetStub.image1.copyWith(id: 'local-1', checksum: 'checksum-1', remoteId: 'remote-1');
final asset2 = LocalAssetStub.image2.copyWith(id: 'local-2', checksum: 'checksum-2', remoteId: 'remote-2');
final deletedAt1 = DateTime(2024, 1, 1);
final deletedAt2 = DateTime(2024, 2, 2);
final remoteDeleted = [
RemoteDeletedLocalAsset(asset: asset1, remoteDeletedAt: deletedAt1),
RemoteDeletedLocalAsset(asset: asset2, remoteDeletedAt: deletedAt2),
];
when(
() => trashSyncRepository.getTrashSyncMoveCandidates(any()),
).thenAnswer((_) async => remoteDeleted.map((candidate) => (albumId: 'album-1', candidate: candidate)).toList());
when(() => assetMediaRepository.deleteAll(any())).thenAnswer((_) async => [asset1.id]);
final result = await sut.resolveRemoteTrash(['checksum-1', 'checksum-2'], isSyncApproved: true);
expect(result, (displayCount: 1, success: false));
final captured =
verify(() => trashedLocalAssetRepository.trashLocalAssets(captureAny())).captured.single
as Iterable<RemoteTrashMoveCandidate>;
expect(captured.map((item) => item.albumId), ['album-1']);
expect(captured.map((item) => item.candidate.remoteDeletedAt), [deletedAt1]);
final capturedChecksums =
verify(() => trashSyncRepository.updateApproves(captureAny(), true)).captured.single as Iterable<String>;
expect(capturedChecksums.toSet(), {'checksum-1'});
});
test('reports success and affected assets when multiple local assets share one checksum', () async {
final asset1 = LocalAssetStub.image1.copyWith(id: 'local-1', checksum: 'checksum-1', remoteId: 'remote-1');
final asset2 = LocalAssetStub.image2.copyWith(id: 'local-2', checksum: 'checksum-1', remoteId: 'remote-2');
final deletedAt1 = DateTime(2024, 1, 1);
final deletedAt2 = DateTime(2024, 2, 2);
final remoteDeleted = [
RemoteDeletedLocalAsset(asset: asset1, remoteDeletedAt: deletedAt1),
RemoteDeletedLocalAsset(asset: asset2, remoteDeletedAt: deletedAt2),
];
when(
() => trashSyncRepository.getTrashSyncMoveCandidates(any()),
).thenAnswer((_) async => remoteDeleted.map((candidate) => (albumId: 'album-1', candidate: candidate)).toList());
when(() => assetMediaRepository.deleteAll(any())).thenAnswer((_) async => [asset1.id, asset2.id]);
final result = await sut.resolveRemoteTrash(['checksum-1'], isSyncApproved: true);
expect(result, (displayCount: 1, success: true));
final captured =
verify(() => trashedLocalAssetRepository.trashLocalAssets(captureAny())).captured.single
as Iterable<RemoteTrashMoveCandidate>;
expect(captured.map((item) => item.albumId), ['album-1', 'album-1']);
expect(captured.map((item) => item.candidate.remoteDeletedAt), [deletedAt1, deletedAt2]);
final capturedChecksums =
verify(() => trashSyncRepository.updateApproves(captureAny(), true)).captured.single as Iterable<String>;
expect(capturedChecksums.toSet(), {'checksum-1'});
});
});
}

View File

@ -91,6 +91,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
@ -122,7 +123,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.share.shouldShow(context), isTrue);
@ -138,7 +140,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.share.shouldShow(context), isTrue);
@ -157,7 +160,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.shareLink.shouldShow(context), isTrue);
@ -174,7 +178,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.shareLink.shouldShow(context), isFalse);
@ -191,7 +196,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.shareLink.shouldShow(context), isFalse);
@ -210,7 +216,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.archive.shouldShow(context), isTrue);
@ -227,7 +234,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.archive.shouldShow(context), isFalse);
@ -244,7 +252,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.archive.shouldShow(context), isFalse);
@ -261,7 +270,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.archive.shouldShow(context), isFalse);
@ -278,7 +288,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.archive.shouldShow(context), isFalse);
@ -297,7 +308,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.unarchive.shouldShow(context), isTrue);
@ -314,7 +326,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.unarchive.shouldShow(context), isFalse);
@ -331,7 +344,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.unarchive.shouldShow(context), isFalse);
@ -350,7 +364,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.download.shouldShow(context), isTrue);
@ -367,7 +382,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.download.shouldShow(context), isFalse);
@ -384,7 +400,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.download.shouldShow(context), isFalse);
@ -403,7 +420,8 @@ void main() {
isStacked: false,
currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.similarPhotos.shouldShow(context), isTrue);
@ -420,7 +438,8 @@ void main() {
currentAlbum: null,
isStacked: false,
advancedTroubleshooting: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.similarPhotos.shouldShow(context), isFalse);
@ -439,7 +458,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.trash.shouldShow(context), isTrue);
@ -456,7 +476,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.trash.shouldShow(context), isFalse);
@ -531,7 +552,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.deletePermanent.shouldShow(context), isTrue);
@ -548,7 +570,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.deletePermanent.shouldShow(context), isFalse);
@ -585,7 +608,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.delete.shouldShow(context), isTrue);
@ -604,7 +628,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.moveToLockFolder.shouldShow(context), isTrue);
@ -623,7 +648,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.deleteLocal.shouldShow(context), isTrue);
@ -640,7 +666,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.deleteLocal.shouldShow(context), isFalse);
@ -656,7 +683,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.deleteLocal.shouldShow(context), isTrue);
@ -675,7 +703,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.upload.shouldShow(context), isTrue);
@ -694,7 +723,8 @@ void main() {
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.removeFromAlbum.shouldShow(context), isTrue);
@ -710,7 +740,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.removeFromAlbum.shouldShow(context), isFalse);
@ -908,7 +939,8 @@ void main() {
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.likeActivity.shouldShow(context), isTrue);
@ -925,7 +957,8 @@ void main() {
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.likeActivity.shouldShow(context), isFalse);
@ -942,7 +975,8 @@ void main() {
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.likeActivity.shouldShow(context), isFalse);
@ -958,7 +992,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.likeActivity.shouldShow(context), isFalse);
@ -976,7 +1011,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: true,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.advancedInfo.shouldShow(context), isTrue);
@ -992,7 +1028,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.advancedInfo.shouldShow(context), isFalse);
@ -1012,6 +1049,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: true,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
@ -1029,6 +1067,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
@ -1046,6 +1085,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
@ -1068,6 +1108,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
});
@ -1087,7 +1128,8 @@ void main() {
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>());
@ -1101,7 +1143,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>());
@ -1131,7 +1174,8 @@ void main() {
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: true,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>());
@ -1155,6 +1199,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
@ -1176,6 +1221,7 @@ void main() {
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
@ -1195,6 +1241,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
@ -1215,6 +1262,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
@ -1229,6 +1277,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);