Compare commits

..

1 Commits

Author SHA1 Message Date
shenlong-tanwen 1ee679f832 fix: re-enable stale asset pruning 2026-06-23 14:54:46 +05:30
6 changed files with 80 additions and 158 deletions
@@ -280,8 +280,7 @@ class SyncStreamService {
return;
// SyncCompleteV1 is used to signal the completion of the sync process. Cleanup stale assets and signal completion
case SyncEntityType.syncCompleteV1:
return;
// return _syncStreamRepository.pruneAssets();
return _syncStreamRepository.pruneAssets();
// Request to reset the client state. Clear everything related to remote entities
case SyncEntityType.syncResetV1:
return _syncStreamRepository.reset();
@@ -1,23 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
abstract class BaseAction {
final IconData icon;
const BaseAction({required this.icon});
String label(BuildContext context);
bool isVisible(BuildContext context, WidgetRef ref);
Future<void> onAction(BuildContext context, WidgetRef ref);
}
abstract class AssetAction<T extends BaseAsset> extends BaseAction {
final List<T> assets;
const AssetAction({required super.icon, required this.assets});
List<T> assetsForAction(BuildContext context, WidgetRef ref);
}
@@ -1,72 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/utils/error_handler.dart';
import 'package:immich_ui/immich_ui.dart';
abstract class BaseActionWidget extends ConsumerWidget {
final BaseAction action;
final void Function(BuildContext _, WidgetRef _)? postAction;
const BaseActionWidget({super.key, required this.action, this.postAction});
Widget buildAction(BuildContext context, Future<void> Function() onPressed);
Future<void> _onPressed(BuildContext context, WidgetRef ref) async {
try {
await action.onAction(context, ref);
} catch (error, stackTrace) {
handleError(context, error, stack: stackTrace, description: 'Action failed: ${action.runtimeType}');
}
if (context.mounted) {
postAction?.call(context, ref);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
if (!action.isVisible(context, ref)) {
return const SizedBox.shrink();
}
return buildAction(context, () => _onPressed(context, ref));
}
}
class ActionIconButtonWidget extends BaseActionWidget {
final ImmichVariant variant;
const ActionIconButtonWidget({super.key, required super.action, this.variant = .ghost, super.postAction});
@override
Widget buildAction(BuildContext context, Future<void> Function() onPressed) =>
ImmichIconButton(icon: action.icon, onPressed: onPressed, variant: variant);
}
class ActionButtonWidget extends BaseActionWidget {
final ImmichVariant variant;
const ActionButtonWidget({super.key, required super.action, this.variant = .ghost, super.postAction});
@override
Widget buildAction(BuildContext context, Future<void> Function() onPressed) =>
ImmichTextButton(labelText: action.label(context), icon: action.icon, onPressed: onPressed, variant: variant);
}
class ActionColumnButtonWidget extends BaseActionWidget {
const ActionColumnButtonWidget({super.key, required super.action, super.postAction});
@override
Widget buildAction(BuildContext context, Future<void> Function() onPressed) =>
ImmichColumnButton(icon: action.icon, label: action.label(context), onPressed: onPressed);
}
class ActionMenuItemWidget extends BaseActionWidget {
const ActionMenuItemWidget({super.key, required super.action, super.postAction});
@override
Widget buildAction(BuildContext context, Future<void> Function() onPressed) =>
ImmichMenuItem(icon: action.icon, label: action.label(context), onPressed: onPressed);
}
-61
View File
@@ -1,61 +0,0 @@
import 'dart:convert';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:openapi/api.dart';
// ignore: depend_on_referenced_packages
import 'package:stack_trace/stack_trace.dart';
void handleError(BuildContext context, Object error, {StackTrace? stack, String? description}) {
String? stackTrace;
if (stack != null) {
final trace = Trace.from(stack);
final clean = trace.foldFrames(
(frame) => frame.package == 'flutter' || frame.package == 'flutter_test' || frame.isCore,
terse: true,
);
stackTrace = clean.toString();
}
dPrint(
() => 'Error${description != null ? ' ($description)' : ''}: $error${stackTrace != null ? '\n$stackTrace' : ''}',
);
if (!context.mounted) {
return;
}
final String message;
if (serverErrorMessage(error) case String serverMessage) {
message = serverMessage;
} else if (isConnectionError(error)) {
message = context.t.login_form_server_error;
} else {
message = context.t.scaffold_body_error_occurred;
}
snackbar.error(message);
}
@visibleForTesting
String? serverErrorMessage(Object error) {
if (error is! ApiException || error.innerException != null || error.message == null) {
return null;
}
try {
final body = jsonDecode(error.message!);
if (body is Map && body['message'] != null) {
final message = body['message'];
return message is List ? message.join(', ') : message.toString();
}
} catch (_) {
// The body was not JSON; fall back to the raw payload below.
}
return error.message;
}
@visibleForTesting
bool isConnectionError(Object error) => error is ApiException && error.innerException != null;
@@ -0,0 +1,64 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import '../repository_context.dart';
void main() {
late MediumRepositoryContext ctx;
late SyncStreamRepository sut;
setUp(() {
ctx = MediumRepositoryContext();
sut = SyncStreamRepository(ctx.db);
});
tearDown(() async {
await ctx.dispose();
});
group('pruneAssets', () {
test('deletes foreign orphans and keeps owned, partner, and in-album assets', () async {
final me = await ctx.newUser();
final partner = await ctx.newUser();
final stranger = await ctx.newUser();
await ctx.newAuthUser(id: me.id);
await ctx.newPartner(sharedById: partner.id, sharedWithId: me.id);
final own = await ctx.newRemoteAsset(ownerId: me.id);
final fromPartner = await ctx.newRemoteAsset(ownerId: partner.id);
final shared = await ctx.newRemoteAsset(ownerId: stranger.id);
await ctx.newRemoteAsset(ownerId: stranger.id);
final album = await ctx.newRemoteAlbum(ownerId: me.id);
await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: shared.id);
await sut.pruneAssets();
final remaining = await ctx.db.select(ctx.db.remoteAssetEntity).get();
expect(remaining.map((a) => a.id), unorderedEquals([own.id, fromPartner.id, shared.id]));
});
test('does nothing when there is no authenticated user', () async {
final stranger = await ctx.newUser();
final orphan = await ctx.newRemoteAsset(ownerId: stranger.id);
await sut.pruneAssets();
final remaining = await ctx.db.select(ctx.db.remoteAssetEntity).get();
expect(remaining.map((a) => a.id), [orphan.id]);
});
test('prunes every stale foreign asset in a large data set', () async {
final stranger = await ctx.newUser();
await ctx.newAuthUser();
for (var i = 0; i < 600; i++) {
await ctx.newRemoteAsset(ownerId: stranger.id);
}
await sut.pruneAssets();
final remaining = await ctx.db.select(ctx.db.remoteAssetEntity).get();
expect(remaining, isEmpty);
});
});
}
@@ -6,6 +6,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/memory.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.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';
@@ -72,6 +73,20 @@ class MediumRepositoryContext {
);
}
Future<AuthUserEntityData> newAuthUser({String? id, String? email, AvatarColor? avatarColor}) async {
id ??= TestUtils.uuid();
return await db
.into(db.authUserEntity)
.insertReturning(
AuthUserEntityCompanion(
id: .new(id),
email: .new(email ?? '$id@test.com'),
name: .new('user_$id'),
avatarColor: .new(avatarColor ?? TestUtils.randElement(AvatarColor.values)),
),
);
}
Future<void> newPartner({required String sharedById, required String sharedWithId, bool? inTimeline}) {
return db
.into(db.partnerEntity)