From fcea617313048d9da1a68a10b4325931e44b64ed Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 13 May 2026 12:07:35 -0400 Subject: [PATCH 1/6] fix: ignore icc profile make and model (#28412) --- server/src/repositories/metadata.repository.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index 57c688cac2..188cc016f1 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { BinaryField, DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored'; +import { BinaryField, DefaultReadTaskOptions, ExifTool, ReadTaskOptions, Tags } from 'exiftool-vendored'; import geotz from 'geo-tz'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { mimeTypes } from 'src/utils/mime-types'; @@ -89,7 +89,7 @@ export class MetadataRepository { geoTz: (lat, lon) => geotz.find(lat, lon)[0], geolocation: true, // Enable exiftool LFS to parse metadata for files larger than 2GB. - readArgs: ['-api', 'largefilesupport=1'], + readArgs: ['-api', 'largefilesupport=1', '--ICC_Profile:DeviceManufacturer', '--ICC_Profile:DeviceModelName'], writeArgs: ['-api', 'largefilesupport=1', '-overwrite_original'], taskTimeoutMillis: 2 * 60 * 1000, }); @@ -107,8 +107,8 @@ export class MetadataRepository { } readTags(path: string): Promise { - const args = mimeTypes.isVideo(path) ? ['-ee'] : []; - return this.exiftool.read(path, { readArgs: args }).catch((error) => { + const options: ReadTaskOptions | undefined = mimeTypes.isVideo(path) ? { readArgs: ['-ee'] } : undefined; + return this.exiftool.read(path, options).catch((error) => { this.logger.warn(`Error reading exif data (${path}): ${error}\n${error?.stack}`); return {}; }) as Promise; From b0315487911d02c8cfb6dda618c6774aee74e331 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Wed, 13 May 2026 22:52:43 +0600 Subject: [PATCH 2/6] fix(mobile): don't block app open on slow validateAccessToken (#28405) * fix(mobile): don't block app open on slow validateAccessToken AuthGuard.onNavigation was async so auto_route awaited the body through validateAccessToken's OS timeout. now it's sync and the validate runs in bg. kicks to login on 401. * fix(mobile): handle re-login race in AuthGuard validate if user logs out + logs back in during a slow validate, the old 401 was logging them out again. now we check the token hasn't changed before redirecting, and dedupe in-flight calls. --------- Co-authored-by: Alex --- mobile/lib/routing/auth_guard.dart | 61 +++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 18 deletions(-) diff --git a/mobile/lib/routing/auth_guard.dart b/mobile/lib/routing/auth_guard.dart index eaa821c0eb..2fc27be4f4 100644 --- a/mobile/lib/routing/auth_guard.dart +++ b/mobile/lib/routing/auth_guard.dart @@ -15,37 +15,62 @@ class AuthGuard extends AutoRouteGuard { final ApiService _apiService; final AuthService _authService; final _log = Logger("AuthGuard"); + bool _validateInFlight = false; AuthGuard(this._apiService, this._authService); @override - void onNavigation(NavigationResolver resolver, StackRouter router) async { - resolver.next(true); - + void onNavigation(NavigationResolver resolver, StackRouter router) { + // Synchronously check for the access token. auto_route awaits async + // guards, so we keep this function fully sync and validate the token in + // the background — otherwise a slow validateAccessToken() request would + // block the route transition for as long as the OS-level HTTP timeout. try { - // Look in the store for an access token Store.get(StoreKey.accessToken); - - // Validate the access token with the server - final res = await _apiService.authenticationApi.validateAccessToken(); - if (res == null || res.authStatus != true) { - // If the access token is invalid, take user back to login - _log.fine('User token is invalid. Redirecting to login'); - unawaited(router.replaceAll([const LoginRoute()]).then((_) => _authService.clearLocalData())); - } } on StoreKeyNotFoundException catch (_) { - // If there is no access token, take us to the login page _log.warning('No access token in the store.'); + resolver.next(false); unawaited(router.replaceAll([const LoginRoute()])); return; + } + + resolver.next(true); + unawaited(_validateAccessTokenInBackground(router)); + } + + Future _validateAccessTokenInBackground(StackRouter router) async { + if (_validateInFlight) { + return; + } + final token = Store.tryGet(StoreKey.accessToken); + if (token == null) { + return; + } + _validateInFlight = true; + try { + final res = await _apiService.authenticationApi.validateAccessToken(); + if (res == null || res.authStatus != true) { + // Token may have changed during validation (user logged out + logged in + // again); only act if it still applies to the current session. + if (Store.tryGet(StoreKey.accessToken) != token) { + return; + } + _log.fine('User token is invalid. Redirecting to login'); + await router.replaceAll([const LoginRoute()]); + await _authService.clearLocalData(); + } } on ApiException catch (e) { - // On an unauthorized request, take us to the login page - if (e.code == HttpStatus.unauthorized) { - _log.warning("Unauthorized access token."); - unawaited(router.replaceAll([const LoginRoute()]).then((_) => _authService.clearLocalData())); + if (e.code != HttpStatus.unauthorized) { return; } + if (Store.tryGet(StoreKey.accessToken) != token) { + return; + } + _log.warning("Unauthorized access token."); + await router.replaceAll([const LoginRoute()]); + await _authService.clearLocalData(); } catch (e) { - // Otherwise, this is not fatal, but we still log the warning _log.warning('Error validating access token from server: $e'); + } finally { + _validateInFlight = false; } } } From aeaf84648210dfe16547de4467f7d05758bf2142 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Thu, 14 May 2026 04:33:57 +0530 Subject: [PATCH 3/6] chore: cleanup unused store keys (#28415) cleanup unused store keys Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/domain/models/store.model.dart | 11 ----- mobile/lib/providers/auth.provider.dart | 4 +- mobile/lib/services/app_settings.service.dart | 6 --- mobile/lib/services/auth.service.dart | 1 - .../settings/notification_setting.dart | 32 ------------ .../domain/services/store_service_test.dart | 15 ++---- .../repositories/store_repository_test.dart | 49 ++++++------------- 7 files changed, 21 insertions(+), 97 deletions(-) diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index e52e8a0a92..63281e49da 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -4,25 +4,15 @@ import 'package:immich_mobile/domain/models/user.model.dart'; /// Defines the data type for each value enum StoreKey { version._(0), - assetETag._(1), currentUser._(2), - deviceIdHash._(3), deviceId._(4), - backupFailedSince._(5), - backupRequireWifi._(6), backupRequireCharging._(7), backupTriggerDelay._(8), serverUrl._(10), accessToken._(11), serverEndpoint._(12), - autoBackup._(13), - backgroundBackup._(14), - sslClientCertData._(15), - sslClientPasswd._(16), - uploadErrorNotificationGracePeriod._(106), selectedAlbumSortOrder._(113), advancedTroubleshooting._(114), - selfSignedCert._(120), selectedAlbumSortReverse._(123), enableHapticFeedback._(126), customHeaders._(127), @@ -38,7 +28,6 @@ enum StoreKey { // Read-only Mode settings readonlyModeEnabled._(138), albumGridView._(140), - loadOriginal._(101), // Experimental stuff enableBackup._(1003), diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart index 825d9e7bc8..4c2a110fde 100644 --- a/mobile/lib/providers/auth.provider.dart +++ b/mobile/lib/providers/auth.provider.dart @@ -11,12 +11,11 @@ import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/auth.service.dart'; +import 'package:immich_mobile/services/background_upload.service.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/services/secure_storage.service.dart'; -import 'package:immich_mobile/services/background_upload.service.dart'; import 'package:immich_mobile/services/widget.service.dart'; import 'package:immich_mobile/utils/debug_print.dart'; -import 'package:immich_mobile/utils/hash.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; @@ -144,7 +143,6 @@ class AuthNotifier extends StateNotifier { // Due to the flow of the code, this will always happen on first login user = serverUser; await Store.put(StoreKey.deviceId, deviceId); - await Store.put(StoreKey.deviceIdHash, fastHash(deviceId)); } } on ApiException catch (error, stackTrace) { if (error.code == 401) { diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index 38d3e028cb..1b9a38bc19 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -2,15 +2,9 @@ import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; enum AppSettingsEnum { - uploadErrorNotificationGracePeriod( - StoreKey.uploadErrorNotificationGracePeriod, - "uploadErrorNotificationGracePeriod", - 2, - ), selectedAlbumSortOrder(StoreKey.selectedAlbumSortOrder, "selectedAlbumSortOrder", 2), advancedTroubleshooting(StoreKey.advancedTroubleshooting, null, false), manageLocalMediaAndroid(StoreKey.manageLocalMediaAndroid, null, false), - allowSelfSignedSSLCert(StoreKey.selfSignedCert, null, false), selectedAlbumSortReverse(StoreKey.selectedAlbumSortReverse, null, true), enableHapticFeedback(StoreKey.enableHapticFeedback, null, true), syncAlbums(StoreKey.syncAlbums, null, false), diff --git a/mobile/lib/services/auth.service.dart b/mobile/lib/services/auth.service.dart index 667681e579..1b5eaab715 100644 --- a/mobile/lib/services/auth.service.dart +++ b/mobile/lib/services/auth.service.dart @@ -123,7 +123,6 @@ class AuthService { _authRepository.clearLocalData(), Store.delete(StoreKey.currentUser), Store.delete(StoreKey.accessToken), - Store.delete(StoreKey.assetETag), Store.delete(StoreKey.autoEndpointSwitching), Store.delete(StoreKey.preferredWifiName), Store.delete(StoreKey.localEndpoint), diff --git a/mobile/lib/widgets/settings/notification_setting.dart b/mobile/lib/widgets/settings/notification_setting.dart index 18a9749a71..46120bb218 100644 --- a/mobile/lib/widgets/settings/notification_setting.dart +++ b/mobile/lib/widgets/settings/notification_setting.dart @@ -3,10 +3,7 @@ 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/providers/notification_permission.provider.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/widgets/settings/settings_button_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:permission_handler/permission_handler.dart'; @@ -16,9 +13,6 @@ class NotificationSetting extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final permissionService = ref.watch(notificationPermissionProvider); - - final sliderValue = useAppSettingsState(AppSettingsEnum.uploadErrorNotificationGracePeriod); - final hasPermission = permissionService == PermissionStatus.granted; openAppNotificationSettings(BuildContext ctx) { @@ -41,8 +35,6 @@ class NotificationSetting extends HookConsumerWidget { ); } - final String formattedValue = _formatSliderValue(sliderValue.value.toDouble()); - final notificationSettings = [ if (!hasPermission) SettingsButtonListTile( @@ -57,32 +49,8 @@ class NotificationSetting extends HookConsumerWidget { } }), ), - SettingsSliderListTile( - enabled: hasPermission, - valueNotifier: sliderValue, - text: 'setting_notifications_notify_failures_grace_period'.tr(namedArgs: {'duration': formattedValue}), - maxValue: 5.0, - noDivisons: 5, - label: formattedValue, - ), ]; return SettingsSubPageScaffold(settings: notificationSettings); } } - -String _formatSliderValue(double v) { - if (v == 0.0) { - return 'setting_notifications_notify_immediately'.tr(); - } else if (v == 1.0) { - return 'setting_notifications_notify_minutes'.tr(namedArgs: {'count': '30'}); - } else if (v == 2.0) { - return 'setting_notifications_notify_hours'.tr(namedArgs: {'count': '2'}); - } else if (v == 3.0) { - return 'setting_notifications_notify_hours'.tr(namedArgs: {'count': '8'}); - } else if (v == 4.0) { - return 'setting_notifications_notify_hours'.tr(namedArgs: {'count': '24'}); - } else { - return 'setting_notifications_notify_never'.tr(); - } -} diff --git a/mobile/test/domain/services/store_service_test.dart b/mobile/test/domain/services/store_service_test.dart index 9f6a30eefe..0a55f8d5c7 100644 --- a/mobile/test/domain/services/store_service_test.dart +++ b/mobile/test/domain/services/store_service_test.dart @@ -9,9 +9,8 @@ import 'package:mocktail/mocktail.dart'; import '../../infrastructure/repository.mock.dart'; const _kAccessToken = '#ThisIsAToken'; -const _kBackgroundBackup = false; +const _kEnableBackup = false; const _kVersion = 2; -final _kBackupFailedSince = DateTime.utc(2023); void main() { late StoreService sut; @@ -24,15 +23,13 @@ void main() { // For generics, we need to provide fallback to each concrete type to avoid runtime errors registerFallbackValue(StoreKey.accessToken); registerFallbackValue(StoreKey.backupTriggerDelay); - registerFallbackValue(StoreKey.backgroundBackup); - registerFallbackValue(StoreKey.backupFailedSince); + registerFallbackValue(StoreKey.enableBackup); when(() => mockDriftStoreRepo.getAll()).thenAnswer( (_) async => [ const StoreDto(StoreKey.accessToken, _kAccessToken), - const StoreDto(StoreKey.backgroundBackup, _kBackgroundBackup), + const StoreDto(StoreKey.enableBackup, _kEnableBackup), const StoreDto(StoreKey.version, _kVersion), - StoreDto(StoreKey.backupFailedSince, _kBackupFailedSince), ], ); when(() => mockDriftStoreRepo.watchAll()).thenAnswer((_) => controller.stream); @@ -49,9 +46,8 @@ void main() { test('Populates the internal cache on init', () { verify(() => mockDriftStoreRepo.getAll()).called(1); expect(sut.tryGet(StoreKey.accessToken), _kAccessToken); - expect(sut.tryGet(StoreKey.backgroundBackup), _kBackgroundBackup); + expect(sut.tryGet(StoreKey.enableBackup), _kEnableBackup); expect(sut.tryGet(StoreKey.version), _kVersion); - expect(sut.tryGet(StoreKey.backupFailedSince), _kBackupFailedSince); // Other keys should be null expect(sut.tryGet(StoreKey.currentUser), isNull); }); @@ -151,9 +147,8 @@ void main() { await sut.clear(); verify(() => mockDriftStoreRepo.deleteAll()).called(1); expect(sut.tryGet(StoreKey.accessToken), isNull); - expect(sut.tryGet(StoreKey.backgroundBackup), isNull); + expect(sut.tryGet(StoreKey.enableBackup), isNull); expect(sut.tryGet(StoreKey.version), isNull); - expect(sut.tryGet(StoreKey.backupFailedSince), isNull); }); }); } diff --git a/mobile/test/infrastructure/repositories/store_repository_test.dart b/mobile/test/infrastructure/repositories/store_repository_test.dart index 806cde9b75..672776b226 100644 --- a/mobile/test/infrastructure/repositories/store_repository_test.dart +++ b/mobile/test/infrastructure/repositories/store_repository_test.dart @@ -12,9 +12,8 @@ import 'package:immich_mobile/infrastructure/repositories/store.repository.dart' import '../../fixtures/user.stub.dart'; const _kTestAccessToken = "#TestToken"; -final _kTestBackupFailed = DateTime(2025, 2, 20, 11, 45); const _kTestVersion = 10; -const _kTestBackupRequireWifi = false; +const _kTestBackupRequireCharging = false; final _kTestUser = UserStub.admin; Future _populateStore(Drift db) async { @@ -22,16 +21,8 @@ Future _populateStore(Drift db) async { batch.insert( db.storeEntity, StoreEntityCompanion( - id: Value(StoreKey.backupRequireWifi.id), - intValue: const Value(_kTestBackupRequireWifi ? 1 : 0), - stringValue: const Value(null), - ), - ); - batch.insert( - db.storeEntity, - StoreEntityCompanion( - id: Value(StoreKey.backupFailedSince.id), - intValue: Value(_kTestBackupFailed.millisecondsSinceEpoch), + id: Value(StoreKey.backupRequireCharging.id), + intValue: const Value(_kTestBackupRequireCharging ? 1 : 0), stringValue: const Value(null), ), ); @@ -84,20 +75,12 @@ void main() { expect(accessToken, _kTestAccessToken); }); - test('converts datetime', () async { - DateTime? backupFailedSince = await sut.tryGet(StoreKey.backupFailedSince); - expect(backupFailedSince, isNull); - await sut.upsert(StoreKey.backupFailedSince, _kTestBackupFailed); - backupFailedSince = await sut.tryGet(StoreKey.backupFailedSince); - expect(backupFailedSince, _kTestBackupFailed); - }); - test('converts bool', () async { - bool? backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi); - expect(backupRequireWifi, isNull); - await sut.upsert(StoreKey.backupRequireWifi, _kTestBackupRequireWifi); - backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi); - expect(backupRequireWifi, _kTestBackupRequireWifi); + bool? backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging); + expect(backupRequireCharging, isNull); + await sut.upsert(StoreKey.backupRequireCharging, _kTestBackupRequireCharging); + backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging); + expect(backupRequireCharging, _kTestBackupRequireCharging); }); test('converts user', () async { @@ -115,11 +98,11 @@ void main() { }); test('delete()', () async { - bool? backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi); - expect(backupRequireWifi, isFalse); - await sut.delete(StoreKey.backupRequireWifi); - backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi); - expect(backupRequireWifi, isNull); + bool? backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging); + expect(backupRequireCharging, isFalse); + await sut.delete(StoreKey.backupRequireCharging); + backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging); + expect(backupRequireCharging, isNull); }); test('deleteAll()', () async { @@ -164,14 +147,12 @@ void main() { emitsInOrder([ [ const StoreDto(StoreKey.version, _kTestVersion), - StoreDto(StoreKey.backupFailedSince, _kTestBackupFailed), - const StoreDto(StoreKey.backupRequireWifi, _kTestBackupRequireWifi), + const StoreDto(StoreKey.backupRequireCharging, _kTestBackupRequireCharging), const StoreDto(StoreKey.accessToken, _kTestAccessToken), ], [ const StoreDto(StoreKey.version, _kTestVersion + 10), - StoreDto(StoreKey.backupFailedSince, _kTestBackupFailed), - const StoreDto(StoreKey.backupRequireWifi, _kTestBackupRequireWifi), + const StoreDto(StoreKey.backupRequireCharging, _kTestBackupRequireCharging), const StoreDto(StoreKey.accessToken, _kTestAccessToken), ], ]), From 3ff0d47ee37cefaaa50e4dbf5a46e5613570a71e Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Thu, 14 May 2026 05:16:24 +0530 Subject: [PATCH 4/6] chore: do not cache dart_tool (#28409) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .github/workflows/build-mobile.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index f3f254e4be..bfbc7bd2e2 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -116,7 +116,6 @@ jobs: ~/.gradle/wrapper ~/.android/sdk mobile/android/.gradle - mobile/.dart_tool key: build-mobile-gradle-${{ runner.os }}-main - name: Setup Android SDK @@ -189,7 +188,6 @@ jobs: ~/.gradle/wrapper ~/.android/sdk mobile/android/.gradle - mobile/.dart_tool key: ${{ steps.cache-gradle-restore.outputs.cache-primary-key }} build-sign-ios: From 89b3433346d9f3797385125ee4149f2cbaeb42f5 Mon Sep 17 00:00:00 2001 From: racehd <22131782+racehd@users.noreply.github.com> Date: Wed, 13 May 2026 19:54:13 -0400 Subject: [PATCH 5/6] feat(docs): add fixed subnet guide for Synology to prevent firewall issues (#26554) * - Add Set Fixed Subnet section - Add newline after details summary to properly render summary with mdx * pnpm run format --write --------- Co-authored-by: Jason Rasmussen --- docs/docs/install/synology.md | 68 +++++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/docs/docs/install/synology.md b/docs/docs/install/synology.md index b86561dbbf..de96886caa 100644 --- a/docs/docs/install/synology.md +++ b/docs/docs/install/synology.md @@ -52,7 +52,7 @@ Scroll to the bottom of the "**Details**" section and find the `IP Address` list ## Step 4 - Configure Firewall Settings -Once your project completes the build process, your containers will start. In order to be able to access Immich from your browser, you need to configure the firewall settings for your Synology NAS. +Once your project completes the build process, your containers will start. In order to be able to access Immich from your browser, you need to configure the firewall settings for your Synology NAS to allow communication between the Immich containers. Open "**Control Panel**" on your Synology NAS, and select "**Security**". Navigate to "**Firewall**" @@ -74,6 +74,7 @@ Read the [Post Installation](/install/post-install.mdx) steps and [upgrade instr
Updating Immich using Container Manager + Check the post installation and upgrade instructions at the links above before proceeding with this section. ## Step 1. Backup @@ -110,7 +111,7 @@ Go to **Project**, select **Action** then **Build**. This will download, unpack, ## Step 5. Update firewall rule -The default behavior is to automatically start the containers once installed. If `immich_server` runs for a few seconds and then stops, it may be because the firewall rule no longer matches the server IP address. +Without a fixed subnet, the default behavior is to automatically start the containers once installed. If `immich_server` runs for a few seconds and then stops, it may be because the firewall rule no longer matches the server IP address. Go to the **Container** section. Click on `immich_server` and scroll down on **General** to find the IP address. ![Container IP](../../static/img/synology-container-ip.png) @@ -123,4 +124,67 @@ In this example, the IP addresses mismatch and the firewall rule needs to be edi ![Edit IP](../../static/img/synology-fw-ipedit.png) +To prevent future firewall issues, you may set a fixed subnet. [See Set Fixed Subnet](#set-fixed-subnet) for instructions. + +
+ +
+ Set Fixed Subnet + +Docker by default assigns dynamic subnets to bridge networks which can change when rebuilding containers and can cause firewall rules to break. To avoid this, define a fixed subnet in your `docker-compose.yml`: + +## Step 1. Determine current subnet + +Go to the **Container** section. Click on `immich_server` and scroll down on **General** to find the IP address. +![Container IP](../../static/img/synology-container-ip.png) + +## Step 2. Add network configuration + +Add the following network configuration at the end of your `docker-compose.yml` file: + +```yaml +networks: + immich-network: + driver: bridge + ipam: + config: + - subnet: 172.20.0.0/16 + gateway: 172.20.0.1 +``` + +If your docker container is running on a different subnet then update accordingly. + +## Step 3. Add network to each service + +Add the network to each service (immich-server, immich-machine-learning, redis, database): + +```yaml +services: + immich-server: + # other config options + networks: + - immich-network + + immich-machine-learning: + # other config options + networks: + - immich-network + + redis: + # other config options + networks: + - immich-network + + database: + # other config options + networks: + - immich-network +``` + +Save your changes. Synology will ask if you want to save changes only or rebuild containers. Select rebuild containers. + +## Step 4. Update Firewall Rules, if necessary + +If your firewall rules were not already set for this subnet, the firewall rules will need to be updated. See [Step 4 - Configure Firewall Settings](#step-4---configure-firewall-settings). +
From 84a2b7a3c82fc39185dc56d6c25c08641e9c2cf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?In=C3=AAs=20Costa?= Date: Thu, 14 May 2026 08:19:00 +0100 Subject: [PATCH 6/6] fix(mobile): add restore option to trashed assets (#27442) --- .../restore_action_button.widget.dart | 55 +++++++++++++++++++ .../asset_viewer/bottom_bar.widget.dart | 22 +++++--- mobile/lib/utils/action_button.utils.dart | 17 +++++- .../action_button_utils_test.dart | 39 +++++++++++++ 4 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 mobile/lib/presentation/widgets/action_buttons/restore_action_button.widget.dart diff --git a/mobile/lib/presentation/widgets/action_buttons/restore_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/restore_action_button.widget.dart new file mode 100644 index 0000000000..1713718967 --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/restore_action_button.widget.dart @@ -0,0 +1,55 @@ +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/domain/models/events.model.dart'; +import 'package:immich_mobile/domain/utils/event_stream.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/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; + +class RestoreActionButton extends ConsumerWidget { + final ActionSource source; + final bool iconOnly; + final bool menuItem; + + const RestoreActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false}); + + void _onTap(BuildContext context, WidgetRef ref) async { + if (!context.mounted) { + return; + } + + final result = await ref.read(actionProvider.notifier).restoreTrash(source); + ref.read(multiSelectProvider.notifier).reset(); + + if (source == ActionSource.viewer) { + EventStream.shared.emit(const ViewerReloadAssetEvent()); + } + + final successMessage = 'assets_restored_count'.t(context: context, args: {'count': result.count.toString()}); + + if (context.mounted) { + ImmichToast.show( + context: context, + msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context), + gravity: ToastGravity.BOTTOM, + toastType: result.success ? ToastType.success : ToastType.error, + ); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return BaseActionButton( + iconData: Icons.history_rounded, + label: 'restore'.t(context: context), + iconOnly: iconOnly, + menuItem: menuItem, + onPressed: () => _onTap(context, ref), + maxWidth: 100.0, + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index ff09d15496..21401f37e5 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -2,15 +2,18 @@ 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/add_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_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/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'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.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/routes.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -33,19 +36,24 @@ class ViewerBottomBar extends ConsumerWidget { final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); final isInLockedView = ref.watch(inLockedViewProvider); final serverInfo = ref.watch(serverInfoProvider); + final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash; final originalTheme = context.themeData; final actions = [ - const ShareActionButton(source: ActionSource.viewer), + if (isInTrash && isOwner && asset.hasRemote) + const RestoreActionButton(source: ActionSource.viewer) + else + const ShareActionButton(source: ActionSource.viewer), if (!isInLockedView) ...[ - 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 (!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) ...[ asset.isLocalOnly ? const DeleteLocalActionButton(source: ActionSource.viewer) diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index d527f3a59e..c38c536136 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -21,6 +21,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_f import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_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/set_album_cover.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; @@ -81,6 +82,7 @@ enum ActionButtonType { moveToLockFolder, removeFromLockFolder, removeFromAlbum, + restoreTrash, trash, deleteLocal, deletePermanent, @@ -112,7 +114,13 @@ enum ActionButtonType { context.isOwner && // !context.isInLockedView && // context.asset.hasRemote && // - context.isTrashEnabled, + context.isTrashEnabled && // + context.timelineOrigin != TimelineOrigin.trash, + ActionButtonType.restoreTrash => + context.isOwner && // + !context.isInLockedView && // + context.asset.hasRemote && // + context.timelineOrigin == TimelineOrigin.trash, ActionButtonType.deletePermanent => context.isOwner && // context.asset.hasRemote && // @@ -201,6 +209,11 @@ enum ActionButtonType { ), ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.trash => TrashActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), + ActionButtonType.restoreTrash => RestoreActionButton( + source: context.source, + iconOnly: iconOnly, + menuItem: menuItem, + ), ActionButtonType.deletePermanent => DeletePermanentActionButton( source: context.source, iconOnly: iconOnly, @@ -292,6 +305,7 @@ enum ActionButtonType { ActionButtonType.moveToLockFolder => 10, ActionButtonType.deleteLocal => 10, ActionButtonType.delete => 10, + ActionButtonType.restoreTrash => 10, // 90: advancedInfo ActionButtonType.advancedInfo => 90, // 1: others @@ -309,6 +323,7 @@ class ActionButtonBuilder { ActionButtonType.delete, ActionButtonType.archive, ActionButtonType.unarchive, + ActionButtonType.restoreTrash, }; static List build(ActionButtonContext context) { diff --git a/mobile/test/utils_legacy/action_button_utils_test.dart b/mobile/test/utils_legacy/action_button_utils_test.dart index 79f4e04b52..8bd078a433 100644 --- a/mobile/test/utils_legacy/action_button_utils_test.dart +++ b/mobile/test/utils_legacy/action_button_utils_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.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/services/timeline.service.dart'; import 'package:immich_mobile/utils/action_button.utils.dart'; LocalAsset createLocalAsset({ @@ -460,6 +461,44 @@ void main() { }); }); + group('restoreTrash button', () { + test('should show when owner, not locked, has remote, and is in trash timeline', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + timelineOrigin: TimelineOrigin.trash, + ); + + expect(ActionButtonType.restoreTrash.shouldShow(context), isTrue); + }); + + test('should not show when not in trash timeline', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: false, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + timelineOrigin: TimelineOrigin.main, + ); + + expect(ActionButtonType.restoreTrash.shouldShow(context), isFalse); + }); + }); + group('deletePermanent button', () { test('should show when owner, not locked, has remote, and trash disabled', () { final remoteAsset = createRemoteAsset();