Merge remote-tracking branch 'origin/main' into feat/integrity-checks-izzy

This commit is contained in:
izzy
2026-02-11 16:00:32 +00:00
245 changed files with 5236 additions and 3790 deletions
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3036,
"android.injected.version.name" => "2.5.5",
"android.injected.version.code" => 3037,
"android.injected.version.name" => "2.5.6",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
+1 -1
View File
@@ -80,7 +80,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.5.5</string>
<string>2.5.6</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
+8 -1
View File
@@ -1,4 +1,11 @@
enum SortOrder { asc, desc }
enum SortOrder {
asc,
desc;
SortOrder reverse() {
return this == SortOrder.asc ? SortOrder.desc : SortOrder.asc;
}
}
enum TextSearchType { context, filename, description, ocr }
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
@@ -36,6 +37,7 @@ class RemoteAlbumService {
AlbumSortMode sortMode, {
bool isReverse = false,
}) async {
// list of albums sorted ascendingly according to the selected sort mode
final List<RemoteAlbum> sorted = switch (sortMode) {
AlbumSortMode.created => albums.sortedBy((album) => album.createdAt),
AlbumSortMode.title => albums.sortedBy((album) => album.name),
@@ -44,8 +46,9 @@ class RemoteAlbumService {
AlbumSortMode.mostRecent => await _sortByNewestAsset(albums),
AlbumSortMode.mostOldest => await _sortByOldestAsset(albums),
};
final effectiveOrder = isReverse ? sortMode.defaultOrder.reverse() : sortMode.defaultOrder;
return (isReverse ? sorted.reversed : sorted).toList();
return (effectiveOrder == SortOrder.asc ? sorted : sorted.reversed).toList();
}
List<RemoteAlbum> searchAlbums(
@@ -209,6 +212,6 @@ class RemoteAlbumService {
return aDate.compareTo(bDate);
});
return sorted.reversed.toList();
return sorted;
}
}
@@ -90,8 +90,14 @@ FROM
(
SELECT
CASE
WHEN :group_by = 0 THEN STRFTIME('%Y-%m-%d', rae.local_date_time)
WHEN :group_by = 1 THEN STRFTIME('%Y-%m', rae.local_date_time)
WHEN :group_by = 0 THEN COALESCE(
STRFTIME('%Y-%m-%d', rae.local_date_time),
STRFTIME('%Y-%m-%d', rae.created_at, 'localtime')
)
WHEN :group_by = 1 THEN COALESCE(
STRFTIME('%Y-%m', rae.local_date_time),
STRFTIME('%Y-%m', rae.created_at, 'localtime')
)
END as bucket_date
FROM
remote_asset_entity rae
+1 -1
View File
@@ -79,7 +79,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
final expandeduserIds = $expandVar($arrayStartIndex, userIds.length);
$arrayStartIndex += userIds.length;
return customSelect(
'SELECT COUNT(*) AS asset_count, bucket_date FROM (SELECT CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', rae.local_date_time) WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', rae.local_date_time) END AS bucket_date FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', lae.created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', lae.created_at, \'localtime\') END AS bucket_date FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2)) GROUP BY bucket_date ORDER BY bucket_date DESC',
'SELECT COUNT(*) AS asset_count, bucket_date FROM (SELECT CASE WHEN ?1 = 0 THEN COALESCE(STRFTIME(\'%Y-%m-%d\', rae.local_date_time), STRFTIME(\'%Y-%m-%d\', rae.created_at, \'localtime\')) WHEN ?1 = 1 THEN COALESCE(STRFTIME(\'%Y-%m\', rae.local_date_time), STRFTIME(\'%Y-%m\', rae.created_at, \'localtime\')) END AS bucket_date FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', lae.created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', lae.created_at, \'localtime\') END AS bucket_date FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2)) GROUP BY bucket_date ORDER BY bucket_date DESC',
variables: [
i0.Variable<int>(groupBy),
for (var $ in userIds) i0.Variable<String>($),
+2 -2
View File
@@ -75,7 +75,7 @@ Future<void> initApp() async {
await EasyLocalization.ensureInitialized();
await initializeDateFormatting();
if (kReleaseMode && Platform.isAndroid) {
if (Platform.isAndroid) {
try {
await FlutterDisplayMode.setHighRefreshRate();
dPrint(() => "Enabled high refresh mode");
@@ -114,7 +114,7 @@ Future<void> initApp() async {
await FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false);
await FileDownloader().trackTasks();
unawaited(FileDownloader().trackTasks());
LicenseRegistry.addLicense(() async* {
for (final license in nonPubLicenses.entries) {
@@ -5,6 +5,7 @@ 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/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@@ -281,6 +282,8 @@ class _SortButtonState extends ConsumerState<_SortButton> {
setState(() {
albumSortOption = sortMode;
isSorting = true;
// reset sort order to default state when switching option
albumSortIsReverse = false;
});
}
@@ -293,6 +296,7 @@ class _SortButtonState extends ConsumerState<_SortButton> {
@override
Widget build(BuildContext context) {
final effectiveOrder = albumSortOption.effectiveOrder(albumSortIsReverse);
return MenuAnchor(
controller: widget.controller,
style: MenuStyle(
@@ -307,7 +311,7 @@ class _SortButtonState extends ConsumerState<_SortButton> {
.map(
(sortMode) => MenuItemButton(
leadingIcon: albumSortOption == sortMode
? albumSortIsReverse
? effectiveOrder == SortOrder.desc
? Icon(
Icons.keyboard_arrow_down,
color: albumSortOption == sortMode
@@ -355,7 +359,7 @@ class _SortButtonState extends ConsumerState<_SortButton> {
children: [
Padding(
padding: const EdgeInsets.only(right: 5),
child: albumSortIsReverse
child: effectiveOrder == SortOrder.desc
? Icon(Icons.keyboard_arrow_down, color: context.colorScheme.onSurface)
: Icon(Icons.keyboard_arrow_up_rounded, color: context.colorScheme.onSurface),
),
@@ -62,6 +62,9 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
return;
}
yield image;
} catch (e) {
PaintingBinding.instance.imageCache.evict(this);
rethrow;
} finally {
this.request = null;
}
@@ -31,7 +31,7 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<Size>('Size', key.size),
],
onDispose: cancel,
onLastListenerRemoved: cancel,
);
}
@@ -76,7 +76,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<Size>('Size', key.size),
],
onDispose: cancel,
onLastListenerRemoved: cancel,
);
}
@@ -9,7 +9,10 @@ import 'package:flutter/painting.dart';
/// An ImageStreamCompleter with support for loading multiple images.
class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
void Function()? _onDispose;
void Function()? _onLastListenerRemoved;
int _listenerCount = 0;
// True once setImage() has been called at least once.
bool didProvideImage = false;
/// The constructor to create an OneFramePlaceholderImageStreamCompleter. The [images]
/// should be the primary images to display (typically asynchronously as they load).
@@ -19,14 +22,18 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
Stream<ImageInfo> images, {
ImageInfo? initialImage,
InformationCollector? informationCollector,
void Function()? onDispose,
void Function()? onLastListenerRemoved,
}) {
if (initialImage != null) {
didProvideImage = true;
setImage(initialImage);
}
_onDispose = onDispose;
_onLastListenerRemoved = onLastListenerRemoved;
images.listen(
setImage,
(image) {
didProvideImage = true;
setImage(image);
},
onError: (Object error, StackTrace stack) {
reportError(
context: ErrorDescription('resolving a single-frame image stream'),
@@ -40,12 +47,24 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
}
@override
void onDisposed() {
final onDispose = _onDispose;
if (onDispose != null) {
_onDispose = null;
onDispose();
void addListener(ImageStreamListener listener) {
super.addListener(listener);
_listenerCount = _listenerCount + 1;
}
@override
void removeListener(ImageStreamListener listener) {
super.removeListener(listener);
_listenerCount = _listenerCount - 1;
final bool onlyCacheListenerLeft = _listenerCount == 1 && !didProvideImage;
final bool noListenersAfterImage = _listenerCount == 0 && didProvideImage;
final onLastListenerRemoved = _onLastListenerRemoved;
if (onLastListenerRemoved != null && (noListenersAfterImage || onlyCacheListenerLeft)) {
_onLastListenerRemoved = null;
onLastListenerRemoved();
}
super.onDisposed();
}
}
@@ -32,7 +32,7 @@ class RemoteImageProvider extends CancellableImageProvider<RemoteImageProvider>
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('URL', key.url),
],
onDispose: cancel,
onLastListenerRemoved: cancel,
);
}
@@ -76,7 +76,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId),
],
onDispose: cancel,
onLastListenerRemoved: cancel,
);
}
@@ -17,7 +17,7 @@ class ThumbHashProvider extends CancellableImageProvider<ThumbHashProvider>
@override
ImageStreamCompleter loadImage(ThumbHashProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter(_loadCodec(key, decode), onDispose: cancel);
return OneFramePlaceholderImageStreamCompleter(_loadCodec(key, decode), onLastListenerRemoved: cancel);
}
Stream<ImageInfo> _loadCodec(ThumbHashProvider key, ImageDecoderCallback decode) {
@@ -233,16 +233,6 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
@override
void dispose() {
final imageProvider = widget.imageProvider;
if (imageProvider is CancellableImageProvider) {
imageProvider.cancel();
}
final thumbhashProvider = widget.thumbhashProvider;
if (thumbhashProvider is CancellableImageProvider) {
thumbhashProvider.cancel();
}
_fadeController.removeStatusListener(_onAnimationStatusChanged);
_fadeController.dispose();
_stopListeningToStream();
@@ -1,4 +1,5 @@
import 'package:collection/collection.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/entities/album.entity.dart';
@@ -73,18 +74,21 @@ class _AlbumSortHandlers {
// Store index allows us to re-arrange the values without affecting the saved prefs
enum AlbumSortMode {
title(1, "library_page_sort_title", _AlbumSortHandlers.title),
assetCount(4, "library_page_sort_asset_count", _AlbumSortHandlers.assetCount),
lastModified(3, "library_page_sort_last_modified", _AlbumSortHandlers.lastModified),
created(0, "library_page_sort_created", _AlbumSortHandlers.created),
mostRecent(2, "sort_recent", _AlbumSortHandlers.mostRecent),
mostOldest(5, "sort_oldest", _AlbumSortHandlers.mostOldest);
title(1, "library_page_sort_title", _AlbumSortHandlers.title, SortOrder.asc),
assetCount(4, "library_page_sort_asset_count", _AlbumSortHandlers.assetCount, SortOrder.desc),
lastModified(3, "library_page_sort_last_modified", _AlbumSortHandlers.lastModified, SortOrder.desc),
created(0, "library_page_sort_created", _AlbumSortHandlers.created, SortOrder.desc),
mostRecent(2, "sort_recent", _AlbumSortHandlers.mostRecent, SortOrder.desc),
mostOldest(5, "sort_oldest", _AlbumSortHandlers.mostOldest, SortOrder.asc);
final int storeIndex;
final String label;
final AlbumSortFn sortFn;
final SortOrder defaultOrder;
const AlbumSortMode(this.storeIndex, this.label, this.sortFn);
const AlbumSortMode(this.storeIndex, this.label, this.sortFn, this.defaultOrder);
SortOrder effectiveOrder(bool isReverse) => isReverse ? defaultOrder.reverse() : defaultOrder;
}
@riverpod
+15 -5
View File
@@ -1,5 +1,6 @@
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/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
@@ -9,6 +10,8 @@ final cleanupServiceProvider = Provider<CleanupService>((ref) {
});
class CleanupService {
static final int _deleteBatchSize = CurrentPlatform.isAndroid ? 2000 : 10000;
final DriftLocalAssetRepository _localAssetRepository;
final AssetMediaRepository _assetMediaRepository;
@@ -35,13 +38,20 @@ class CleanupService {
return 0;
}
final deletedIds = await _assetMediaRepository.deleteAll(localIds);
if (deletedIds.isNotEmpty) {
await _localAssetRepository.delete(deletedIds);
return deletedIds.length;
int deletedCount = 0;
for (int index = 0; index < localIds.length; index += _deleteBatchSize) {
final end = index + _deleteBatchSize < localIds.length ? index + _deleteBatchSize : localIds.length;
final batch = localIds.sublist(index, end);
final deletedIds = await _assetMediaRepository.deleteAll(batch);
if (deletedIds.isNotEmpty) {
await _localAssetRepository.delete(deletedIds);
deletedCount += deletedIds.length;
}
}
return 0;
return deletedCount;
}
/// Returns album IDs that should be kept by default (e.g., messaging app albums)
+4 -6
View File
@@ -27,19 +27,17 @@ import 'package:isar/isar.dart';
import 'package:path_provider/path_provider.dart';
void configureFileDownloaderNotifications() {
final fileName = 'file_name'.t(args: {'file_name': '{filename}'});
FileDownloader().configureNotificationForGroup(
kDownloadGroupImage,
running: TaskNotification('downloading_media'.t(), fileName),
complete: TaskNotification('download_finished'.t(), fileName),
running: TaskNotification('downloading_media'.t(), '${'file_name_text'.t()}: {filename}'),
complete: TaskNotification('download_finished'.t(), '${'file_name_text'.t()}: {filename}'),
progressBar: true,
);
FileDownloader().configureNotificationForGroup(
kDownloadGroupVideo,
running: TaskNotification('downloading_media'.t(), fileName),
complete: TaskNotification('download_finished'.t(), fileName),
running: TaskNotification('downloading_media'.t(), '${'file_name_text'.t()}: {filename}'),
complete: TaskNotification('download_finished'.t(), '${'file_name_text'.t()}: {filename}'),
progressBar: true,
);
@@ -128,6 +128,9 @@ class _ProfileIndicator extends ConsumerWidget {
const widgetSize = 30.0;
// TODO: remove this when update Flutter version newer than 3.35.7
final isIpad = defaultTargetPlatform == TargetPlatform.iOS && !context.isMobile;
void toggleReadonlyMode() {
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
ref.read(readonlyModeProvider.notifier).toggleReadonlyMode();
@@ -144,7 +147,12 @@ class _ProfileIndicator extends ConsumerWidget {
}
return InkWell(
onTap: () => showDialog(context: context, useRootNavigator: false, builder: (ctx) => const ImmichAppBarDialog()),
onTap: () => showDialog(
context: context,
useRootNavigator: false,
barrierDismissible: !isIpad,
builder: (ctx) => const ImmichAppBarDialog(),
),
onLongPress: () => toggleReadonlyMode(),
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: Badge(
@@ -1,5 +1,3 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
@@ -20,7 +18,7 @@ class UserCircleAvatar extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final userAvatarColor = user.avatarColor.toColor();
final profileImageUrl =
'${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${Random().nextInt(1024)}';
'${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${user.profileChangedAt.millisecondsSinceEpoch}';
final textIcon = DefaultTextStyle(
style: TextStyle(
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 2.5.5+3036
version: 2.5.6+3037
environment:
sdk: '>=3.8.0 <4.0.0'
@@ -85,35 +85,47 @@ void main() {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, AlbumSortMode.created);
expect(result, [albumA, albumB]);
expect(result, [albumB, albumA]);
});
test('should sort correctly based on updatedAt', () async {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, AlbumSortMode.lastModified);
expect(result, [albumA, albumB]);
expect(result, [albumB, albumA]);
});
test('should sort correctly based on assetCount', () async {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, AlbumSortMode.assetCount);
expect(result, [albumA, albumB]);
expect(result, [albumB, albumA]);
});
test('should sort correctly based on newestAssetTimestamp', () async {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, AlbumSortMode.mostRecent);
expect(result, [albumA, albumB]);
expect(result, [albumB, albumA]);
});
test('should sort correctly based on oldestAssetTimestamp', () async {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, AlbumSortMode.mostOldest);
expect(result, [albumB, albumA]);
expect(result, [albumA, albumB]);
});
test('should flip order when isReverse is true for all modes', () async {
final albums = [albumB, albumA];
for (final mode in AlbumSortMode.values) {
final normal = await sut.sortAlbums(albums, mode, isReverse: false);
final reversed = await sut.sortAlbums(albums, mode, isReverse: true);
// reversed should be the exact inverse of normal
expect(reversed, normal.reversed.toList(), reason: 'Mode: $mode');
}
});
});
}
@@ -0,0 +1,51 @@
import 'package:drift/drift.dart';
import 'package:drift/native.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/timeline.model.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
void main() {
late Drift db;
setUp(() {
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
});
tearDown(() async {
await db.close();
});
test('mergedBucket falls back to createdAt when localDateTime is null', () async {
const userId = 'user-1';
final createdAt = DateTime(2024, 1, 1, 12);
await db
.into(db.userEntity)
.insert(UserEntityCompanion.insert(id: userId, email: 'user-1@test.dev', name: 'User 1'));
await db
.into(db.remoteAssetEntity)
.insert(
RemoteAssetEntityCompanion.insert(
id: 'asset-1',
name: 'asset-1.jpg',
type: AssetType.image,
checksum: 'checksum-1',
ownerId: userId,
visibility: AssetVisibility.timeline,
createdAt: Value(createdAt),
updatedAt: Value(createdAt),
localDateTime: const Value(null),
),
);
final buckets = await db.mergedAssetDrift.mergedBucket(groupBy: GroupAssetsBy.day.index, userIds: [userId]).get();
expect(buckets, hasLength(1));
expect(buckets.single.assetCount, 1);
expect(buckets.single.bucketDate, isNotEmpty);
});
}
@@ -0,0 +1,73 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/services/cleanup.service.dart';
import 'package:mocktail/mocktail.dart';
import '../infrastructure/repository.mock.dart';
import '../repository.mocks.dart';
void main() {
late CleanupService sut;
late MockDriftLocalAssetRepository localAssetRepository;
late MockAssetMediaRepository assetMediaRepository;
setUp(() {
localAssetRepository = MockDriftLocalAssetRepository();
assetMediaRepository = MockAssetMediaRepository();
sut = CleanupService(localAssetRepository, assetMediaRepository);
});
group('CleanupService.deleteLocalAssets', () {
test('returns 0 and does nothing for empty input', () async {
final result = await sut.deleteLocalAssets([]);
expect(result, 0);
verifyNever(() => assetMediaRepository.deleteAll(any()));
verifyNever(() => localAssetRepository.delete(any()));
});
test('deletes in a single batch when under limit', () async {
final ids = List.generate(999, (i) => 'asset-$i');
when(() => assetMediaRepository.deleteAll(any())).thenAnswer((invocation) async {
return (invocation.positionalArguments.first as List<String>).toList();
});
when(() => localAssetRepository.delete(any())).thenAnswer((_) async {});
final result = await sut.deleteLocalAssets(ids);
expect(result, ids.length);
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
verify(() => localAssetRepository.delete(ids)).called(1);
});
test('deletes in platform-specific batches when over limit', () async {
final batchSize = CurrentPlatform.isAndroid ? 2000 : 10000;
final ids = List.generate(batchSize * 2 + 501, (i) => 'asset-$i');
final capturedBatches = <List<String>>[];
when(() => assetMediaRepository.deleteAll(any())).thenAnswer((invocation) async {
final batch = (invocation.positionalArguments.first as List<String>).toList();
capturedBatches.add(batch);
return batch;
});
when(() => localAssetRepository.delete(any())).thenAnswer((_) async {});
final result = await sut.deleteLocalAssets(ids);
expect(result, ids.length);
expect(capturedBatches.length, 3);
expect(capturedBatches[0].length, batchSize);
expect(capturedBatches[1].length, batchSize);
expect(capturedBatches[2].length, 501);
expect(capturedBatches[0].first, 'asset-0');
expect(capturedBatches[0].last, 'asset-${batchSize - 1}');
expect(capturedBatches[1].first, 'asset-$batchSize');
expect(capturedBatches[1].last, 'asset-${batchSize * 2 - 1}');
expect(capturedBatches[2].first, 'asset-${batchSize * 2}');
expect(capturedBatches[2].last, 'asset-${batchSize * 2 + 500}');
verify(() => localAssetRepository.delete(any())).called(3);
});
});
}