Compare commits

..

3 Commits

Author SHA1 Message Date
renovate[bot] c631b48661 fix(deps): update mobile 2026-06-30 10:49:43 +00:00
Daniel Dietzler deeb042a9e feat: honor album access permissions in search endpoints (#29352) 2026-06-29 22:27:22 +02:00
Daniel Dietzler b4cc406a3f fix!: search endpoints visibility can be omitted (#29385) 2026-06-29 22:00:02 +02:00
15 changed files with 202 additions and 439 deletions
@@ -52,10 +52,6 @@ class RemoteAsset extends BaseAsset {
bool get isTrashed => deletedAt != null;
bool get isStacked => stackId != null;
bool get isArchived => visibility == .archive;
@override
String toString() {
return '''Asset {
@@ -3,23 +3,25 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/utils/asset_filter.dart';
import 'package:immich_ui/immich_ui.dart';
class FavoriteAction extends AssetAction<RemoteAsset> {
final bool favorite;
final bool shouldFavorite;
FavoriteAction({required super.assets}) : favorite = assets.any((asset) => !asset.isFavorite);
FavoriteAction({required super.assets}) : shouldFavorite = assets.any((asset) => !asset.isFavorite);
@override
IconData get icon => favorite ? Icons.favorite_border_rounded : Icons.favorite_rounded;
IconData get icon => shouldFavorite ? Icons.favorite_border_rounded : Icons.favorite_rounded;
@override
String label(ActionScope scope) => favorite ? scope.context.t.favorite : scope.context.t.unfavorite;
String label(ActionScope scope) => shouldFavorite ? scope.context.t.favorite : scope.context.t.unfavorite;
@override
Iterable<RemoteAsset> filter(ActionScope scope) =>
AssetFilter(assets).owned(scope.authUser.id).favorite(isFavorite: !favorite);
Iterable<RemoteAsset> filter(ActionScope scope) => assets
.where(
(asset) => asset is RemoteAsset && asset.ownerId == scope.authUser.id && asset.isFavorite == !shouldFavorite,
)
.cast<RemoteAsset>();
@override
bool isVisible(ActionScope scope) => filter(scope).isNotEmpty;
@@ -29,8 +31,8 @@ class FavoriteAction extends AssetAction<RemoteAsset> {
final ActionScope(:ref) = scope;
final assets = filter(scope).map((asset) => asset.id).toList(growable: false);
await ref.read(assetServiceProvider).updateFavorite(assets, favorite);
final message = favorite
await ref.read(assetServiceProvider).updateFavorite(assets, shouldFavorite);
final message = shouldFavorite
? StaticTranslations.instance.favorite_action_prompt(count: assets.length)
: StaticTranslations.instance.unfavorite_action_prompt(count: assets.length);
snackbar.success(message);
-22
View File
@@ -1,22 +0,0 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
extension type const AssetFilter<T extends BaseAsset>(Iterable<T> assets) implements Iterable<T> {
AssetFilter<T> where(bool Function(T asset) test) => AssetFilter(assets.where(test));
AssetFilter<T> whereNot(bool Function(T asset) test) => AssetFilter(assets.where((asset) => !test(asset)));
AssetFilter<T> type(AssetType type) => where((asset) => asset.type == type);
AssetFilter<T> favorite({bool isFavorite = true}) => where((asset) => asset.isFavorite == isFavorite);
AssetFilter<RemoteAsset> remote() => AssetFilter(assets.whereType<RemoteAsset>());
AssetFilter<RemoteAsset> owned(String ownerId) => remote().where((asset) => asset.ownerId == ownerId);
AssetFilter<RemoteAsset> visibility(AssetVisibility visibility) =>
remote().where((asset) => asset.visibility == visibility);
AssetFilter<RemoteAsset> notVisibility(AssetVisibility visibility) =>
remote().where((asset) => asset.visibility != visibility);
AssetFilter<RemoteAsset> archived({bool isArchived = true}) =>
remote().where((asset) => asset.isArchived == isArchived);
AssetFilter<RemoteAsset> stacked({bool isStacked = true}) => remote().where((asset) => asset.isStacked == isStacked);
AssetFilter<LocalAsset> local() => AssetFilter(assets.whereType<LocalAsset>());
AssetFilter<LocalAsset> backedUp() => local().where((asset) => asset.remoteAssetId != null);
}
+50 -140
View File
@@ -1,186 +1,96 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools."aqua:flutter/flutter"]]
version = "3.44.1"
version = "3.44.4"
backend = "aqua:flutter/flutter"
[tools."aqua:flutter/flutter"."platforms.linux-arm64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.1-stable.tar.xz"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.4-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-arm64-musl"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.1-stable.tar.xz"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.4-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.1-stable.tar.xz"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.4-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-x64-musl"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.1-stable.tar.xz"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.4-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.macos-arm64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_3.44.1-stable.zip"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_3.44.4-stable.zip"
[tools."aqua:flutter/flutter"."platforms.macos-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_3.44.1-stable.zip"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_3.44.4-stable.zip"
[tools."aqua:flutter/flutter"."platforms.windows-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_3.44.1-stable.zip"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_3.44.4-stable.zip"
[[tools."github:CQLabs/homebrew-dcm"]]
version = "1.37.0"
backend = "github:CQLabs/homebrew-dcm"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-arm64"]
checksum = "sha256:253da2512b149913dfe345bf9a62a79acb2d730f66e71162ba4a92dfc4224b82"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543838"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-arm64-musl"]
checksum = "sha256:253da2512b149913dfe345bf9a62a79acb2d730f66e71162ba4a92dfc4224b82"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543838"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-x64"]
checksum = "sha256:477e086d4099c12f21e5ccd83b005d5fb945dd4cac4fd127fd9a08d7649af1cf"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543797"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-x64-musl"]
checksum = "sha256:477e086d4099c12f21e5ccd83b005d5fb945dd4cac4fd127fd9a08d7649af1cf"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543797"
[tools."github:CQLabs/homebrew-dcm"."platforms.macos-arm64"]
checksum = "sha256:30bede64367d09067093cc57af6ec9496d7717898138ded5cb98a16ac8dd9d93"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-macos-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543757"
[tools."github:CQLabs/homebrew-dcm"."platforms.macos-x64"]
checksum = "sha256:e56cb99872be7445a4de1d37e5438ca70e3bcd83be7a2b9b385e3538881f8068"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-macos-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543727"
[tools."github:CQLabs/homebrew-dcm"."platforms.windows-x64"]
checksum = "sha256:f133470daa3fb0427f039b424392af7e917d7e7db6b556aa2a968ab0e31587da"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-windows-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543660"
[[tools."github:CQLabs/homebrew-dcm"]]
version = "1.37.0"
version = "1.38.1"
backend = "github:CQLabs/homebrew-dcm"
[tools."github:CQLabs/homebrew-dcm".options]
asset_pattern = "dcm-linux-arm-release.zip"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-arm64"]
checksum = "sha256:253da2512b149913dfe345bf9a62a79acb2d730f66e71162ba4a92dfc4224b82"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543838"
checksum = "sha256:0934337f9838fd2c74615070a8b1c0cb80f1b8261080a6e03aa71c16642d06e6"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.38.1/dcm-linux-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/455844707"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-arm64-musl"]
checksum = "sha256:253da2512b149913dfe345bf9a62a79acb2d730f66e71162ba4a92dfc4224b82"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543838"
checksum = "sha256:0934337f9838fd2c74615070a8b1c0cb80f1b8261080a6e03aa71c16642d06e6"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.38.1/dcm-linux-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/455844707"
[[tools."github:CQLabs/homebrew-dcm"]]
version = "1.37.0"
backend = "github:CQLabs/homebrew-dcm"
[tools."github:CQLabs/homebrew-dcm".options]
asset_pattern = "dcm-linux-x64-release.zip"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-x64"]
checksum = "sha256:477e086d4099c12f21e5ccd83b005d5fb945dd4cac4fd127fd9a08d7649af1cf"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543797"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-x64-musl"]
checksum = "sha256:477e086d4099c12f21e5ccd83b005d5fb945dd4cac4fd127fd9a08d7649af1cf"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543797"
[[tools."github:CQLabs/homebrew-dcm"]]
version = "1.37.0"
backend = "github:CQLabs/homebrew-dcm"
[tools."github:CQLabs/homebrew-dcm".options]
asset_pattern = "dcm-macos-x64-release.zip"
[tools."github:CQLabs/homebrew-dcm"."platforms.macos-x64"]
checksum = "sha256:e56cb99872be7445a4de1d37e5438ca70e3bcd83be7a2b9b385e3538881f8068"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-macos-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543727"
[[tools."github:CQLabs/homebrew-dcm"]]
version = "1.37.0"
backend = "github:CQLabs/homebrew-dcm"
[tools."github:CQLabs/homebrew-dcm".options]
asset_pattern = "dcm-windows-release.zip"
[tools."github:CQLabs/homebrew-dcm"."platforms.windows-x64"]
checksum = "sha256:f133470daa3fb0427f039b424392af7e917d7e7db6b556aa2a968ab0e31587da"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-windows-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543660"
[[tools."github:CQLabs/homebrew-dcm"]]
version = "1.37.0"
version = "1.38.1"
backend = "github:CQLabs/homebrew-dcm"
[tools."github:CQLabs/homebrew-dcm".options]
asset_pattern = "dcm-macos-arm-release.zip"
[tools."github:CQLabs/homebrew-dcm"."platforms.macos-arm64"]
checksum = "sha256:30bede64367d09067093cc57af6ec9496d7717898138ded5cb98a16ac8dd9d93"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-macos-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543757"
checksum = "sha256:a08e0e5e881ce04885e787b24aa942597188950d5e53c66f45c2293b70758c5b"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.38.1/dcm-macos-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/455844382"
[[tools.java]]
version = "21.0.2"
backend = "core:java"
[[tools."github:CQLabs/homebrew-dcm"]]
version = "1.38.1"
backend = "github:CQLabs/homebrew-dcm"
[tools.java."platforms.linux-arm64"]
checksum = "sha256:08db1392a48d4eb5ea5315cf8f18b89dbaf36cda663ba882cf03c704c9257ec2"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-aarch64_bin.tar.gz"
[tools."github:CQLabs/homebrew-dcm".options]
asset_pattern = "dcm-linux-x64-release.zip"
[tools.java."platforms.linux-x64"]
checksum = "sha256:a2def047a73941e01a73739f92755f86b895811afb1f91243db214cff5bdac3f"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-x64_bin.tar.gz"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-x64"]
checksum = "sha256:27ea2b517a393b70f1abd9111b02b557854a89abcdfe756a4bded3cea3cff0aa"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.38.1/dcm-linux-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/455844566"
[tools.java."platforms.macos-arm64"]
checksum = "sha256:b3d588e16ec1e0ef9805d8a696591bd518a5cea62567da8f53b5ce32d11d22e4"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-aarch64_bin.tar.gz"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-x64-musl"]
checksum = "sha256:27ea2b517a393b70f1abd9111b02b557854a89abcdfe756a4bded3cea3cff0aa"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.38.1/dcm-linux-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/455844566"
[tools.java."platforms.macos-x64"]
checksum = "sha256:8fd09e15dc406387a0aba70bf5d99692874e999bf9cd9208b452b5d76ac922d3"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-x64_bin.tar.gz"
[[tools."github:CQLabs/homebrew-dcm"]]
version = "1.38.1"
backend = "github:CQLabs/homebrew-dcm"
[tools.java."platforms.windows-x64"]
checksum = "sha256:b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip"
[tools."github:CQLabs/homebrew-dcm".options]
asset_pattern = "dcm-macos-x64-release.zip"
[[tools.java]]
version = "21.0.2"
backend = "core:java"
[tools."github:CQLabs/homebrew-dcm"."platforms.macos-x64"]
checksum = "sha256:27182502dfc6dab9181be9f0d1b3669468afb127cd2bcba09d534693e96cbe9a"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.38.1/dcm-macos-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/455844091"
[tools.java.options]
shorthand_vendor = "openjdk"
[[tools."github:CQLabs/homebrew-dcm"]]
version = "1.38.1"
backend = "github:CQLabs/homebrew-dcm"
[tools.java."platforms.linux-arm64"]
checksum = "sha256:08db1392a48d4eb5ea5315cf8f18b89dbaf36cda663ba882cf03c704c9257ec2"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-aarch64_bin.tar.gz"
[tools."github:CQLabs/homebrew-dcm".options]
asset_pattern = "dcm-windows-release.zip"
[tools.java."platforms.linux-x64"]
checksum = "sha256:a2def047a73941e01a73739f92755f86b895811afb1f91243db214cff5bdac3f"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-x64_bin.tar.gz"
[tools.java."platforms.macos-arm64"]
checksum = "sha256:b3d588e16ec1e0ef9805d8a696591bd518a5cea62567da8f53b5ce32d11d22e4"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-aarch64_bin.tar.gz"
[tools.java."platforms.macos-x64"]
checksum = "sha256:8fd09e15dc406387a0aba70bf5d99692874e999bf9cd9208b452b5d76ac922d3"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-x64_bin.tar.gz"
[tools.java."platforms.windows-x64"]
checksum = "sha256:b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip"
[tools."github:CQLabs/homebrew-dcm"."platforms.windows-x64"]
checksum = "sha256:ef51b1f3e6312db9c3e4727f8e834972e93a9477358d8bb34c0481c3cc18a79e"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.38.1/dcm-windows-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/455843911"
+3 -3
View File
@@ -1,9 +1,9 @@
[tools]
"aqua:flutter/flutter" = "3.44.1"
java = "21.0.2"
"aqua:flutter/flutter" = "3.44.4"
java = "21.0.11+10.0.LTS"
[tools."github:CQLabs/homebrew-dcm"]
version = "1.37.0"
version = "1.38.1"
bin = "dcm"
postinstall = "chmod +x \"$MISE_TOOL_INSTALL_PATH/dcm\" || true"
+6 -6
View File
@@ -6,7 +6,7 @@ version: 3.0.0-rc.4+3052
environment:
sdk: '>=3.12.0 <4.0.0'
flutter: 3.44.1
flutter: 3.44.4
dependencies:
async: ^2.13.1
@@ -35,7 +35,7 @@ dependencies:
flutter_web_auth_2: ^5.0.2
fluttertoast: ^8.2.14
geolocator: ^14.0.2
home_widget: ^0.8.1
home_widget: ^0.9.0
hooks_riverpod: ^2.6.1
http: ^1.6.0
image_picker: ^1.2.1
@@ -44,7 +44,7 @@ dependencies:
intl: ^0.20.2
local_auth: ^2.3.0
logging: ^1.3.0
maplibre_gl: ^0.22.0
maplibre_gl: ^0.26.0
native_video_player:
git:
url: https://github.com/immich-app/native_video_player
@@ -68,10 +68,10 @@ dependencies:
sliver_tools: ^0.2.12
stream_transform: ^2.1.1
sqlite3: ^3.3.2
sqlite_async: 0.14.2
sqlite_async: 0.14.3
sqlite3_connection_pool: ^0.2.6
thumbhash: 0.1.0+1
timezone: ^0.9.4
timezone: ^0.11.0
url_launcher: ^6.3.2
uuid: ^4.5.3
wakelock_plus: ^1.3.3
@@ -84,7 +84,7 @@ dependencies:
cupertino_http:
git:
url: https://github.com/mertalev/http
ref: 'a0a933358517c6d01cff37fc2a2752ee2d744a3c' # https://github.com/dart-lang/http/pull/1876
ref: '0.13.4' # https://github.com/dart-lang/http/pull/1876
path: pkgs/cupertino_http/
ok_http:
git:
@@ -5,14 +5,7 @@ import '../../utils.dart';
class RemoteAssetFactory {
const RemoteAssetFactory();
static RemoteAsset create({
String? id,
String? name,
String? ownerId,
bool isFavorite = false,
AssetVisibility visibility = AssetVisibility.timeline,
String? stackId,
}) {
static RemoteAsset create({String? id, String? name, String? ownerId, bool isFavorite = false}) {
id = TestUtils.uuid(id);
return RemoteAsset(
@@ -24,8 +17,6 @@ class RemoteAssetFactory {
createdAt: TestUtils.yesterday(),
updatedAt: TestUtils.now(),
isFavorite: isFavorite,
visibility: visibility,
stackId: stackId,
isEdited: false,
);
}
@@ -1,200 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/utils/asset_filter.dart';
import '../factories/local_asset_factory.dart';
import '../factories/remote_asset_factory.dart';
void main() {
group('AssetFilter', () {
group('type promotion', () {
test('a bare filter retains every BaseAsset', () {
final remoteAsset = RemoteAssetFactory.create();
final localAsset = LocalAssetFactory.create();
final AssetFilter<BaseAsset> filter = AssetFilter(<BaseAsset>[remoteAsset, localAsset]);
expect(filter.toList(), [remoteAsset, localAsset]);
});
test('remote keeps only remote assets', () {
final remoteAsset = RemoteAssetFactory.create();
final localAsset = LocalAssetFactory.create();
final AssetFilter<RemoteAsset> remoteOnly = AssetFilter(<BaseAsset>[remoteAsset, localAsset]).remote();
expect(remoteOnly.toList(), [remoteAsset]);
});
test('local keeps only local assets', () {
final remoteAsset = RemoteAssetFactory.create();
final localAsset = LocalAssetFactory.create();
final AssetFilter<LocalAsset> localOnly = AssetFilter(<BaseAsset>[remoteAsset, localAsset]).local();
expect(localOnly.toList(), [localAsset]);
});
test('owned promotes to RemoteAsset and drops local assets', () {
final remoteAsset = RemoteAssetFactory.create();
final localAsset = LocalAssetFactory.create();
final AssetFilter<RemoteAsset> remoteOnly = AssetFilter(<BaseAsset>[
remoteAsset,
localAsset,
]).owned(remoteAsset.ownerId);
expect(remoteOnly.toList(), [remoteAsset]);
});
test('backedUp promotes to LocalAsset and drops remote assets', () {
final syncedPhoto = LocalAssetFactory.create().copyWith(remoteId: 'remote');
final offlinePhoto = LocalAssetFactory.create();
final remotePhoto = RemoteAssetFactory.create();
final AssetFilter<LocalAsset> syncedPhotos = AssetFilter(<BaseAsset>[
syncedPhoto,
offlinePhoto,
remotePhoto,
]).backedUp();
expect(syncedPhotos.toList(), [syncedPhoto]);
});
});
group('named filters', () {
test('owned keeps only assets of the given owner', () {
final asset1 = RemoteAssetFactory.create();
final asset2 = RemoteAssetFactory.create();
final alexPhotos = AssetFilter([asset1, asset2]).owned(asset1.ownerId);
expect(alexPhotos.toList(), [asset1]);
});
test('favorites keeps only favorite assets', () {
final asset1 = RemoteAssetFactory.create(isFavorite: true);
final asset2 = RemoteAssetFactory.create(ownerId: asset1.ownerId);
final favorites = AssetFilter([asset1, asset2]).favorite();
expect(favorites.toList(), [asset1]);
});
test('type keeps only assets of the given type', () {
final image = RemoteAssetFactory.create();
final video = RemoteAssetFactory.create(ownerId: image.ownerId).copyWith(type: .video);
final videos = AssetFilter([image, video]).type(.video);
expect(videos.toList(), [video]);
});
test('visibility keeps only assets with the given visibility', () {
final locked = RemoteAssetFactory.create(visibility: AssetVisibility.locked);
final onTimeline = RemoteAssetFactory.create(ownerId: locked.ownerId);
final lockedPhotos = AssetFilter([locked, onTimeline]).visibility(.locked);
expect(lockedPhotos.toList(), [locked]);
});
test('archived keeps only archived assets', () {
final archived = RemoteAssetFactory.create(visibility: AssetVisibility.archive);
final onTimeline = RemoteAssetFactory.create(ownerId: archived.ownerId);
final archivedPhotos = AssetFilter([archived, onTimeline]).archived();
expect(archivedPhotos.toList(), [archived]);
});
test('stacked keeps only assets belonging to a stack', () {
final stacked = RemoteAssetFactory.create(stackId: 'stack');
final loose = RemoteAssetFactory.create(ownerId: stacked.ownerId);
final stackedPhotos = AssetFilter([stacked, loose]).stacked();
expect(stackedPhotos.toList(), [stacked]);
});
});
group('inversion', () {
test('notArchived keeps every non-archived visibility', () {
final archived = RemoteAssetFactory.create(visibility: AssetVisibility.archive);
final onTimeline = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.timeline);
final hidden = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.hidden);
final locked = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.locked);
final visiblePhotos = AssetFilter([archived, onTimeline, hidden, locked]).archived(isArchived: false);
expect(visiblePhotos.toSet(), {onTimeline, hidden, locked});
});
test('notVisibility keeps every asset not at the target visibility', () {
final archived = RemoteAssetFactory.create(visibility: AssetVisibility.archive);
final onTimeline = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.timeline);
final locked = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.locked);
final toArchive = AssetFilter([archived, onTimeline, locked]).notVisibility(.archive);
expect(toArchive.toSet(), {onTimeline, locked});
});
test('notStacked keeps only assets without a stack', () {
final stacked = RemoteAssetFactory.create(stackId: 'stack');
final loose = RemoteAssetFactory.create(ownerId: stacked.ownerId);
final loosePhotos = AssetFilter([stacked, loose]).stacked(isStacked: false);
expect(loosePhotos.toList(), [loose]);
});
test('whereNot inverts an arbitrary predicate', () {
final favorite = RemoteAssetFactory.create(isFavorite: true);
final regular = RemoteAssetFactory.create(ownerId: favorite.ownerId);
final nonFavorites = AssetFilter([favorite, regular]).whereNot((asset) => asset.isFavorite);
expect(nonFavorites.toList(), [regular]);
});
test('notFavorites keeps only non-favorite assets', () {
final favorite = RemoteAssetFactory.create(isFavorite: true);
final regular = RemoteAssetFactory.create(ownerId: favorite.ownerId);
final nonFavorites = AssetFilter([favorite, regular]).favorite(isFavorite: false);
expect(nonFavorites.toList(), [regular]);
});
});
group('chaining', () {
test('combines predicates across owner, visibility and stack', () {
final asset = RemoteAssetFactory.create();
final wrongOwner = RemoteAssetFactory.create();
final archived = RemoteAssetFactory.create(ownerId: asset.ownerId, visibility: AssetVisibility.archive);
final stacked = RemoteAssetFactory.create(ownerId: asset.ownerId, stackId: 'stack-1');
final localPhoto = LocalAssetFactory.create();
final result = AssetFilter(<BaseAsset>[
asset,
wrongOwner,
archived,
stacked,
localPhoto,
]).owned(asset.ownerId).archived(isArchived: false).stacked(isStacked: false);
expect(result.toList(), [asset]);
});
test('a base filter after a promotion retains the promoted type', () {
final favorite = RemoteAssetFactory.create(isFavorite: true);
final regular = RemoteAssetFactory.create(ownerId: favorite.ownerId);
final AssetFilter<RemoteAsset> result = AssetFilter([favorite, regular]).owned(favorite.ownerId).favorite();
expect(result.toList(), [favorite]);
});
});
});
}
+28 -33
View File
@@ -7,18 +7,17 @@ from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
"asset"."fileCreatedAt" >= $1
and "asset_exif"."lensModel" = $2
and "asset"."ownerId" = any ($3::uuid[])
and "asset"."isFavorite" = $4
and "asset"."deletedAt" is null
order by
"asset"."fileCreatedAt" desc
limit
$6
$5
offset
$7
$6
-- SearchRepository.searchStatistics
select
@@ -27,11 +26,10 @@ from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
"asset"."fileCreatedAt" >= $1
and "asset_exif"."lensModel" = $2
and "asset"."ownerId" = any ($3::uuid[])
and "asset"."isFavorite" = $4
and "asset"."deletedAt" is null
-- SearchRepository.searchRandom
@@ -41,16 +39,15 @@ from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
"asset"."fileCreatedAt" >= $1
and "asset_exif"."lensModel" = $2
and "asset"."ownerId" = any ($3::uuid[])
and "asset"."isFavorite" = $4
and "asset"."deletedAt" is null
order by
random()
limit
$6
$5
-- SearchRepository.searchLargeAssets
select
@@ -60,17 +57,16 @@ from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
"asset"."fileCreatedAt" >= $1
and "asset_exif"."lensModel" = $2
and "asset"."ownerId" = any ($3::uuid[])
and "asset"."isFavorite" = $4
and "asset"."deletedAt" is null
and "asset_exif"."fileSizeInByte" > $6
and "asset_exif"."fileSizeInByte" > $5
order by
"asset_exif"."fileSizeInByte" desc
limit
$7
$6
-- SearchRepository.searchSmart
begin
@@ -83,18 +79,17 @@ from
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
inner join "smart_search" on "asset"."id" = "smart_search"."assetId"
where
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
"asset"."fileCreatedAt" >= $1
and "asset_exif"."lensModel" = $2
and "asset"."ownerId" = any ($3::uuid[])
and "asset"."isFavorite" = $4
and "asset"."deletedAt" is null
order by
smart_search.embedding <=> $6
smart_search.embedding <=> $5
limit
$7
$6
offset
$8
$7
commit
-- SearchRepository.getEmbedding
+4 -3
View File
@@ -117,7 +117,8 @@ type BaseAssetSearchOptions = SearchDateOptions &
SearchAlbumOptions &
SearchOcrOptions;
export type AssetSearchOptions = BaseAssetSearchOptions & SearchRelationOptions;
export type AssetSearchOptions = Omit<BaseAssetSearchOptions, 'visibility'> &
SearchRelationOptions & { visibility?: AssetVisibility | 'not-locked' };
export type AssetSearchBuilderOptions = Omit<AssetSearchOptions, 'orderDirection'>;
@@ -125,11 +126,11 @@ export type SmartSearchOptions = SearchDateOptions &
SearchEmbeddingOptions &
SearchExifOptions &
SearchOneToOneRelationOptions &
SearchStatusOptions &
Omit<SearchStatusOptions, 'visibility'> &
SearchUserIdOptions &
SearchPeopleOptions &
SearchTagOptions &
SearchOcrOptions;
SearchOcrOptions & { visibility?: AssetVisibility | 'not-locked' };
export type OcrSearchOptions = SearchDateOptions & SearchOcrOptions;
+1 -1
View File
@@ -250,7 +250,7 @@ describe(SearchService.name, () => {
);
expect(mocks.search.searchSmart).toHaveBeenCalledWith(
{ page: 1, size: 100 },
{ query: 'test', embedding: '[1, 2, 3]', userIds: [authStub.user1.user.id] },
{ query: 'test', embedding: '[1, 2, 3]', userIds: [authStub.user1.user.id], visibility: 'not-locked' },
);
});
+24 -3
View File
@@ -73,14 +73,22 @@ export class SearchService extends BaseService {
checksum = Buffer.from(dto.checksum, encoding);
}
let userIds: string[] | undefined;
if (dto.albumIds && dto.albumIds.length > 0) {
await this.requireAccess({ auth, ids: dto.albumIds, permission: Permission.AlbumRead });
} else {
userIds = await this.getUserIdsToSearch(auth, dto.visibility);
}
const page = dto.page ?? 1;
const size = dto.size || 250;
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
const { hasNextPage, items } = await this.searchRepository.searchMetadata(
{ page, size },
{
...dto,
checksum,
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
userIds,
orderDirection: dto.order ?? AssetOrder.Desc,
},
@@ -91,9 +99,13 @@ export class SearchService extends BaseService {
async searchStatistics(auth: AuthDto, dto: StatisticsSearchDto): Promise<SearchStatisticsResponseDto> {
const userIds = await this.getUserIdsToSearch(auth);
if (dto.visibility === AssetVisibility.Locked) {
requireElevatedPermission(auth);
}
return await this.searchRepository.searchStatistics({
...dto,
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
userIds,
});
}
@@ -114,7 +126,11 @@ export class SearchService extends BaseService {
}
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
const items = await this.searchRepository.searchLargeAssets(dto.size || 250, { ...dto, userIds });
const items = await this.searchRepository.searchLargeAssets(dto.size || 250, {
...dto,
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
userIds,
});
return items.map((item) => mapAsset(item, { auth }));
}
@@ -155,7 +171,12 @@ export class SearchService extends BaseService {
const size = dto.size || 100;
const { hasNextPage, items } = await this.searchRepository.searchSmart(
{ page, size },
{ ...dto, userIds: await userIds, embedding },
{
...dto,
userIds: await userIds,
embedding,
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
},
);
return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth });
+5 -2
View File
@@ -373,12 +373,15 @@ const joinDeduplicationPlugin = new DeduplicateJoinsPlugin();
export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuilderOptions) {
options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore || options.isOffline);
const visibility = options.visibility == null ? AssetVisibility.Timeline : options.visibility;
return kysely
.withPlugin(joinDeduplicationPlugin)
.selectFrom('asset')
.where('asset.visibility', '=', visibility)
.$if(!!options.visibility, (qb) =>
options.visibility === 'not-locked'
? qb.where('asset.visibility', '!=', AssetVisibility.Locked)
: qb.where('asset.visibility', '=', options.visibility!),
)
.$if(!!options.albumIds && options.albumIds.length > 0, (qb) => inAlbums(qb, options.albumIds!))
.$if(!!options.tagIds && options.tagIds.length > 0, (qb) => hasTags(qb, options.tagIds!))
.$if(options.tagIds === null, (qb) =>
@@ -1,5 +1,6 @@
import { Kysely } from 'kysely';
import { SearchSuggestionType } from 'src/dtos/search.dto';
import { AlbumUserRole, AssetVisibility } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
@@ -108,6 +109,71 @@ describe(SearchService.name, () => {
expect(response.assets.items.length).toBe(1);
expect(response.assets.items[0].id).toBe(unstackedAsset.id);
});
describe('visibility', () => {
it('should filter out locked assets in a default session', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Locked });
const auth = factory.auth({ user: { id: user.id } });
const response = await sut.searchMetadata(auth, { withStacked: false });
expect(response.assets.items.length).toBe(0);
});
it('should return locked assets in an elevated session', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Locked });
const auth = factory.auth({ user: { id: user.id }, session: { hasElevatedPermission: true } });
const response = await sut.searchMetadata(auth, { withStacked: false });
expect(response.assets.items.length).toBe(1);
});
});
});
describe('albumIds option', () => {
it('should return assets from shared album', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: otherUser } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: otherUser.id });
const { album } = await ctx.newAlbum({ ownerId: otherUser.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumUser({ albumId: album.id, userId: user.id, role: AlbumUserRole.Editor });
const auth = factory.auth({ user: { id: user.id } });
const response = await sut.searchMetadata(auth, { albumIds: [album.id] });
expect(response.assets.items.length).toBe(1);
});
it('should not return assets for album, a user is not in, when partner sharing is enabled', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: otherUser } = await ctx.newUser();
await ctx.newPartner({ sharedById: otherUser.id, sharedWithId: user.id });
const { asset } = await ctx.newAsset({ ownerId: otherUser.id });
const { album } = await ctx.newAlbum({ ownerId: otherUser.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
const auth = factory.auth({ user: { id: user.id } });
await expect(sut.searchMetadata(auth, { albumIds: [album.id] })).rejects.toThrow(
'Not found or no album.read access',
);
});
});
describe('getSearchSuggestions', () => {
+3 -3
View File
@@ -27,7 +27,7 @@ const authFactory = ({
user,
}: {
apiKey?: Partial<AuthApiKey>;
session?: { id: string };
session?: { id?: string; hasElevatedPermission?: boolean };
user?: Omit<
Partial<UserAdmin>,
'createdAt' | 'updatedAt' | 'deletedAt' | 'fileCreatedAt' | 'fileModifiedAt' | 'localDateTime' | 'profileChangedAt'
@@ -46,8 +46,8 @@ const authFactory = ({
if (session) {
auth.session = {
id: session.id,
hasElevatedPermission: false,
id: session.id ?? newUuid(),
hasElevatedPermission: session.hasElevatedPermission ?? false,
};
}