Merge 025c986b68 into c42cea5ca9
commit
8b4f6ff11d
19
i18n/en.json
19
i18n/en.json
|
|
@ -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
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
}
|
||||
|
|
@ -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)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)';
|
||||
}
|
||||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
enum TrashSyncMode { off, autoSync, review }
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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};
|
||||
}
|
||||
|
|
@ -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)',
|
||||
);
|
||||
|
|
@ -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
|
|
@ -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);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>{};
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, _) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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', () {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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'});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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'});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue