Compare commits

..

4 Commits

Author SHA1 Message Date
shenlong-tanwen bbf5d0642a add todo 2026-06-29 19:51:29 +05:30
shenlong-tanwen 7adbb567de make edits non static 2026-06-29 19:17:36 +05:30
shenlong-tanwen 34e4a62856 cleanup test 2026-06-29 19:17:36 +05:30
shenlong-tanwen aec9cb1e12 feat: edit asset action
# Conflicts:
#	mobile/test/unit/mocks.dart
2026-06-29 19:17:36 +05:30
30 changed files with 332 additions and 511 deletions
+2 -2
View File
@@ -103,7 +103,7 @@ jobs:
working-directory: ./mobile
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
- uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: 'zulu'
java-version: '17'
@@ -237,7 +237,7 @@ jobs:
run: flutter build ios --config-only --no-codesign
- name: Setup Ruby
uses: ruby/setup-ruby@9eb537ca036ebaed86729dcb9309076e4c5c3b74 # v1.314.0
uses: ruby/setup-ruby@89f90524b88a01fe6e0b732220432cc6142926af # v1.313.0
with:
ruby-version: '3.3'
bundler-cache: true
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
persist-credentials: false
- name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@ccc2442df0d99f8c419ed73e3de88641c91b3bc6 # v0.1.3
uses: oasdiff/oasdiff-action/breaking@e24529087d93f837b28b50bb66ba9016380a7fcc # v0.1.2
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json
+1 -1
View File
@@ -149,7 +149,7 @@ jobs:
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
draft: true
prerelease: ${{ needs.bump_version.outputs.rc }}
-1
View File
@@ -7,7 +7,6 @@ project(native_buffer LANGUAGES C)
add_library(native_buffer SHARED
src/main/cpp/native_buffer.c
src/main/cpp/native_image.c
)
target_link_libraries(native_buffer jnigraphics)
@@ -1,109 +0,0 @@
#include <jni.h>
#include <stdlib.h>
#include <stdint.h>
#include <android/bitmap.h>
// Cache-friendly block size for the tiled rotation (in pixels). 32x32 uint32 = 4KB, fits L1.
#define TILE 32
// EXIF orientation values (androidx.exifinterface.media.ExifInterface.ORIENTATION_*).
enum {
ORIENTATION_FLIP_HORIZONTAL = 2,
ORIENTATION_ROTATE_180 = 3,
ORIENTATION_FLIP_VERTICAL = 4,
ORIENTATION_TRANSPOSE = 5,
ORIENTATION_ROTATE_90 = 6,
ORIENTATION_TRANSVERSE = 7,
ORIENTATION_ROTATE_270 = 8,
};
// The orientations that swap width and height. Must stay in sync with affine_for's dim usage.
static int swaps_dims(int o) {
return o == ORIENTATION_ROTATE_90 || o == ORIENTATION_ROTATE_270 ||
o == ORIENTATION_TRANSPOSE || o == ORIENTATION_TRANSVERSE;
}
// A source pixel (sx, sy) maps to destination index base + sx*stepX + sy*stepY, where dw is the
// destination width. This affine form covers all 8 EXIF orientations and matches the pixel layout
// of Bitmap.createBitmap(src, matrixForExifOrientation(o)). int64_t so it stays correct on
// armeabi-v7a (32-bit long) regardless of how large MAX_RAW_DECODE_PIXELS grows.
static void affine_for(int o, int sw, int sh, int dw, int64_t *base, int64_t *stepX, int64_t *stepY) {
switch (o) {
case ORIENTATION_ROTATE_90: *base = sh - 1; *stepX = dw; *stepY = -1; break;
case ORIENTATION_ROTATE_270: *base = (int64_t) (sw - 1) * dw; *stepX = -dw; *stepY = 1; break;
case ORIENTATION_ROTATE_180: *base = (int64_t) (sh - 1) * dw + (sw - 1); *stepX = -1; *stepY = -dw; break;
case ORIENTATION_FLIP_HORIZONTAL: *base = sw - 1; *stepX = -1; *stepY = dw; break;
case ORIENTATION_FLIP_VERTICAL: *base = (int64_t) (sh - 1) * dw; *stepX = 1; *stepY = -dw; break;
case ORIENTATION_TRANSPOSE: *base = 0; *stepX = dw; *stepY = 1; break;
case ORIENTATION_TRANSVERSE: *base = (int64_t) (sw - 1) * dw + (sh - 1); *stepX = -dw; *stepY = -1; break;
default: *base = 0; *stepX = 1; *stepY = dw; break;
}
}
// Copy each source pixel (whole uint32, so channel order/premult is irrelevant) to its rotated
// destination, walking TILE x TILE blocks so the scattered writes of a 90/270 transpose stay
// cache-resident. dst is densely packed (rowBytes == dw*4, no padding), which the affine math relies on.
static void rotate_tiled(const uint8_t *src, int srcStride, uint32_t *dst,
int sw, int sh, int64_t base, int64_t stepX, int64_t stepY) {
for (int ty = 0; ty < sh; ty += TILE) {
int yEnd = ty + TILE < sh ? ty + TILE : sh;
for (int tx = 0; tx < sw; tx += TILE) {
int xEnd = tx + TILE < sw ? tx + TILE : sw;
for (int sy = ty; sy < yEnd; sy++) {
const uint32_t *srcRow = (const uint32_t *) (src + (size_t) sy * srcStride);
int64_t idx = base + (int64_t) sy * stepY + (int64_t) tx * stepX;
for (int sx = tx; sx < xEnd; sx++) {
dst[idx] = srcRow[sx];
idx += stepX;
}
}
}
}
}
// Rotates an RGBA_8888 bitmap to the given EXIF orientation into a freshly malloc'd buffer (free it
// via NativeBuffer.free). Fills outInfo with {width, height, rowBytes} and returns the buffer
// address, or 0 if the bitmap can't be handled (e.g. a non-8888 format) so the caller can fall back.
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_NativeImage_rotate(
JNIEnv *env, jclass clazz, jobject bitmap, jint orientation, jintArray outInfo) {
AndroidBitmapInfo info;
if (AndroidBitmap_getInfo(env, bitmap, &info) != ANDROID_BITMAP_RESULT_SUCCESS) {
return 0;
}
if (info.format != ANDROID_BITMAP_FORMAT_RGBA_8888) {
return 0;
}
int sw = (int) info.width;
int sh = (int) info.height;
int dw = swaps_dims(orientation) ? sh : sw;
int dh = swaps_dims(orientation) ? sw : sh;
uint32_t *dst = (uint32_t *) malloc((size_t) dw * dh * 4);
if (dst == NULL) {
return 0;
}
void *srcPixels = NULL;
if (AndroidBitmap_lockPixels(env, bitmap, &srcPixels) != ANDROID_BITMAP_RESULT_SUCCESS) {
free(dst);
return 0;
}
int64_t base, stepX, stepY;
affine_for(orientation, sw, sh, dw, &base, &stepX, &stepY);
rotate_tiled((const uint8_t *) srcPixels, (int) info.stride, dst, sw, sh, base, stepX, stepY);
AndroidBitmap_unlockPixels(env, bitmap);
jint dims[3] = {dw, dh, dw * 4};
(*env)->SetIntArrayRegion(env, outInfo, 0, 3, dims);
// Keep ownership in C until the buffer is safely handed back: if outInfo was somehow too small,
// SetIntArrayRegion left a pending exception and Kotlin will never receive (or free) dst.
if ((*env)->ExceptionCheck(env)) {
free(dst);
return 0;
}
return (jlong) dst;
}
@@ -1,19 +0,0 @@
package app.alextran.immich
import android.graphics.Bitmap
object NativeImage {
init {
// rotate() is compiled into the native_buffer shared lib (which already links jnigraphics).
System.loadLibrary("native_buffer")
}
/**
* Rotates an RGBA_8888 [bitmap] to the given EXIF [orientation], writing the result into a freshly
* malloc'd native buffer. Returns the buffer address (free it with [NativeBuffer.free]) and fills
* [outInfo] with {width, height, rowBytes}. Returns 0 when the bitmap can't be handled (e.g. a
* non-8888 config) so the caller can fall back.
*/
@JvmStatic
external fun rotate(bitmap: Bitmap, orientation: Int, outInfo: IntArray): Long
}
@@ -12,9 +12,7 @@ import android.provider.MediaStore.Images
import android.provider.MediaStore.Video
import android.util.Size
import androidx.annotation.RequiresApi
import androidx.exifinterface.media.ExifInterface
import app.alextran.immich.NativeBuffer
import app.alextran.immich.NativeImage
import kotlin.math.*
import java.io.IOException
import java.util.concurrent.Executors
@@ -183,88 +181,35 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
val id = assetId.toLong()
signal.throwIfCanceled()
val bitmap = if (isVideo) {
decodeVideoThumbnail(id, size, signal)
} else {
decodeImage(id, size, signal)
}
try {
val res = if (isVideo) {
decodeVideoThumbnail(id, size, signal).toNativeBuffer()
} else {
val (bitmap, orientation) = decodeImage(id, size, signal)
signal.throwIfCanceled()
if (orientation == ExifInterface.ORIENTATION_NORMAL || orientation == ExifInterface.ORIENTATION_UNDEFINED) {
bitmap.toNativeBuffer()
} else {
rotateToNativeBuffer(bitmap, orientation, signal)
}
}
// Don't re-check cancellation here: res owns a malloc'd buffer, and bailing to CANCELLED would
// orphan it. Deliver it; Dart frees the buffer itself if the request was cancelled meanwhile.
signal.throwIfCanceled()
val res = bitmap.toNativeBuffer()
signal.throwIfCanceled()
callback(Result.success(res))
} catch (e: Exception) {
callback(if (e is OperationCanceledException) CANCELLED else Result.failure(e))
}
}
// Returns the decoded bitmap plus the EXIF orientation that still needs applying. Only Q+ raw
// decodes come back unrotated (ImageDecoder / loadThumbnail skip EXIF for raw like DNG); every
// other path already orients itself, so it reports ORIENTATION_NORMAL.
private fun decodeImage(id: Long, size: Size, signal: CancellationSignal): Pair<Bitmap, Int> {
private fun decodeImage(id: Long, size: Size, signal: CancellationSignal): Bitmap {
signal.throwIfCanceled()
val uri = ContentUris.withAppendedId(Images.Media.EXTERNAL_CONTENT_URI, id)
val handleRaw = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isRawMime(uri)
val orientation = if (handleRaw) rawOrientation(uri) else ExifInterface.ORIENTATION_NORMAL
if (size.width <= 0 || size.height <= 0 || size.width > 768 || size.height > 768) {
// A "load original" request is unsized -> a full-res decode (a sized > 768 just samples to target).
return decodeSource(uri, size, signal) to orientation
return decodeSource(uri, size, signal)
}
val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
resolver.loadThumbnail(uri, size, signal)
} else {
signal.setOnCancelListener { Images.Thumbnails.cancelThumbnailRequest(resolver, id) }
Images.Thumbnails.getThumbnail(resolver, id, Images.Thumbnails.MINI_KIND, OPTIONS)
}
return bitmap to orientation
}
private fun isRawMime(uri: Uri): Boolean {
val mime = resolver.getType(uri) ?: return false
return mime.startsWith("image/x-") || mime == "image/dng"
}
private fun rawOrientation(uri: Uri): Int {
return resolver.openInputStream(uri)?.use {
ExifInterface(it).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
} ?: ExifInterface.ORIENTATION_NORMAL
}
// ImageDecoder / loadThumbnail skip EXIF orientation for raw (e.g. DNG) on Q+, so the decoded
// bitmap comes back unrotated. Rotate it into the output buffer in native code (one pass, no
// intermediate rotated bitmap).
private fun rotateToNativeBuffer(bitmap: Bitmap, orientation: Int, signal: CancellationSignal): Map<String, Long> {
signal.throwIfCanceled()
// Force ARGB_8888: the native rotate needs a lockable 8888 buffer, and toNativeBuffer() below
// allocates width*height*4 (an F16/HDR decode would otherwise under-allocate). No-op for the
// common already-8888 case.
val src = if (bitmap.config != Bitmap.Config.ARGB_8888) {
val converted = bitmap.copy(Bitmap.Config.ARGB_8888, false)
bitmap.recycle()
converted ?: throw IOException("could not convert bitmap to ARGB_8888")
} else {
bitmap
}
try {
val info = IntArray(3)
val pointer = NativeImage.rotate(src, orientation, info)
if (pointer == 0L) throw IOException("native rotate failed for orientation $orientation")
return mapOf(
"pointer" to pointer,
"width" to info[0].toLong(),
"height" to info[1].toLong(),
"rowBytes" to info[2].toLong()
)
} finally {
if (!src.isRecycled) src.recycle()
}
}
private fun decodeVideoThumbnail(id: Long, target: Size, signal: CancellationSignal): Bitmap {
@@ -1,5 +1,6 @@
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
@@ -77,4 +78,12 @@ class AssetService {
await _apiRepository.updateFavorite(remoteIds, isFavorite);
await _remoteRepository.updateFavorite(remoteIds, isFavorite);
}
Future<void> applyEdits(String remoteId, List<AssetEdit> edits) async {
if (edits.isEmpty) {
await _apiRepository.removeEdits(remoteId);
} else {
await _apiRepository.editAsset(remoteId, edits);
}
}
}
@@ -0,0 +1,71 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/pages/edit/editor.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/semver.dart';
class EditAssetAction extends AssetAction<RemoteAsset> {
const EditAssetAction({required super.assets});
@override
IconData get icon => Icons.tune;
@override
String label(ActionScope scope) => scope.context.t.edit;
@override
Iterable<RemoteAsset> filter(ActionScope scope) =>
assets.whereType<RemoteAsset>().where((asset) => asset.ownerId == scope.authUser.id && asset.isEditable);
@override
bool isVisible(ActionScope scope) =>
filter(scope).length == 1 &&
scope.ref.watch(serverInfoProvider).serverVersion >= const SemVer(major: 2, minor: 6, patch: 0);
@override
Future<void> onAction(ActionScope scope) async {
final ActionScope(:context, :ref) = scope;
final asset = filter(scope).first;
// TODO(shenlong): Move all EXIF and Apply Edits logic onto the Route
final remoteId = asset.id;
final repository = ref.read(remoteAssetRepositoryProvider);
final (edits, exif) = await (repository.getAssetEdits(remoteId), repository.getExif(remoteId)).wait;
if (exif == null || !context.mounted) {
return;
}
ref.read(editorStateProvider.notifier).init(edits, exif);
unawaited(
context.pushRoute(
DriftEditImageRoute(
image: Image(image: getFullImageProvider(asset, edited: false)),
applyEdits: (newEdits) => applyEdits(ref, remoteId, newEdits),
),
),
);
}
}
@visibleForTesting
Future<void> applyEdits(WidgetRef ref, String remoteId, List<AssetEdit> edits) async {
final websocket = ref.read(websocketProvider.notifier);
bool isCurrentId(dynamic data) => data is Map && (data['asset'] as Map?)?['id'] == remoteId;
await ref.read(assetServiceProvider).applyEdits(remoteId, edits);
await Future.any([
websocket.waitForEvent('AssetEditReadyV1', isCurrentId, const Duration(seconds: 10)),
websocket.waitForEvent('AssetEditReadyV2', isCurrentId, const Duration(seconds: 10)),
]).catchError((_) {});
}
@@ -21,7 +21,7 @@ class FavoriteAction extends AssetAction<RemoteAsset> {
.where(
(asset) => asset is RemoteAsset && asset.ownerId == scope.authUser.id && asset.isFavorite == !shouldFavorite,
)
.cast<RemoteAsset>();
.cast();
@override
bool isVisible(ActionScope scope) => filter(scope).isNotEmpty;
@@ -1,59 +0,0 @@
import 'dart:async';
import 'package:auto_route/auto_route.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/asset_edit.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/pages/edit/editor.provider.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class EditImageActionButton extends ConsumerWidget {
const EditImageActionButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
Future<void> editImage(List<AssetEdit> edits) async {
if (currentAsset == null || currentAsset.remoteId == null) {
return;
}
await ref.read(actionProvider.notifier).applyEdits(ActionSource.viewer, edits);
}
Future<void> onPress() async {
if (currentAsset == null || currentAsset.remoteId == null) {
return;
}
final imageProvider = getFullImageProvider(currentAsset, edited: false);
final image = Image(image: imageProvider);
final (edits, exifInfo) = await (
ref.read(remoteAssetRepositoryProvider).getAssetEdits(currentAsset.remoteId!),
ref.read(remoteAssetRepositoryProvider).getExif(currentAsset.remoteId!),
).wait;
if (exifInfo == null) {
return;
}
ref.read(editorStateProvider.notifier).init(edits, exifInfo);
await context.pushRoute(DriftEditImageRoute(image: image, applyEdits: editImage));
}
return BaseActionButton(
iconData: Icons.tune,
label: "edit".t(context: context),
onPressed: onPress,
);
}
}
@@ -4,11 +4,12 @@ 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/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/edit.action.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/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/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';
@@ -17,10 +18,9 @@ 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';
import 'package:immich_mobile/utils/semver.dart';
import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart';
import 'package:immich_ui/immich_ui.dart';
class ViewerBottomBar extends ConsumerWidget {
const ViewerBottomBar({super.key});
@@ -37,10 +37,10 @@ class ViewerBottomBar extends ConsumerWidget {
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
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 actionAsset = [asset];
final actions = <Widget>[
if (isInTrash && isOwner && asset.hasRemote)
@@ -51,9 +51,7 @@ class ViewerBottomBar extends ConsumerWidget {
if (!isInLockedView) ...[
if (!isInTrash) ...[
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
// edit sync was added in 2.6.0
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
const EditImageActionButton(),
ActionColumnButtonWidget(action: EditAssetAction(assets: actionAsset)),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
],
if (isOwner) ...[
@@ -104,7 +102,10 @@ class ViewerBottomBar extends ConsumerWidget {
OcrToggleButton(asset: asset),
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
if (!isReadonlyModeEnabled)
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
ImmichColorOverride(
color: Colors.white,
child: Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
),
],
),
),
@@ -1,13 +1,11 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:background_downloader/background_downloader.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/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
@@ -17,18 +15,13 @@ import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider;
import 'package:immich_mobile/providers/infrastructure/tag.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/action.service.dart';
import 'package:immich_mobile/services/download.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
final actionProvider = NotifierProvider<ActionNotifier, void>(ActionNotifier.new, dependencies: [multiSelectProvider]);
@@ -135,16 +128,6 @@ class ActionNotifier extends Notifier<void> {
};
}
Future<ActionResult> troubleshoot(ActionSource source, BuildContext context) async {
final assets = _getAssets(source);
if (assets.length > 1) {
return ActionResult(count: assets.length, success: false, error: 'Cannot troubleshoot multiple assets');
}
unawaited(context.pushRoute(AssetTroubleshootRoute(asset: assets.first)));
return ActionResult(count: assets.length, success: true);
}
Future<ActionResult> shareLink(ActionSource source, BuildContext context) async {
final ids = _getRemoteIdsForSource(source);
try {
@@ -631,37 +614,6 @@ class ActionNotifier extends Notifier<void> {
});
}
}
Future<ActionResult> applyEdits(ActionSource source, List<AssetEdit> edits) async {
final ids = _getOwnedRemoteIdsForSource(source);
if (ids.length != 1) {
_logger.warning('applyEdits called with multiple assets, expected single asset');
return ActionResult(count: ids.length, success: false, error: 'Expected single asset for applying edits');
}
Future<void> editReady;
if (ref.read(serverInfoProvider).serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)) {
editReady = ref.read(websocketProvider.notifier).waitForEvent("AssetEditReadyV2", (dynamic data) {
final eventAsset = SyncAssetV2.fromJson(data["asset"]);
return eventAsset?.id == ids.first;
}, const Duration(seconds: 10));
} else {
editReady = ref.read(websocketProvider.notifier).waitForEvent("AssetEditReadyV1", (dynamic data) {
final eventAsset = SyncAssetV1.fromJson(data["asset"]);
return eventAsset?.id == ids.first;
}, const Duration(seconds: 10));
}
try {
await _service.applyEdits(ids.first, edits);
await editReady;
return const ActionResult(count: 1, success: true);
} catch (error, stack) {
_logger.severe('Failed to apply edits to assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
}
extension on Iterable<RemoteAsset> {
-9
View File
@@ -5,7 +5,6 @@ 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/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/tag.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
@@ -305,14 +304,6 @@ class ActionService {
return true;
}
Future<void> applyEdits(String remoteId, List<AssetEdit> edits) async {
if (edits.isEmpty) {
await _assetApiRepository.removeEdits(remoteId);
} else {
await _assetApiRepository.editAsset(remoteId, edits);
}
}
Future<int> _deleteLocalAssets(List<String> localIds) async {
final deletedIds = await _assetMediaRepository.deleteAll(localIds);
if (deletedIds.isEmpty) {
+3
View File
@@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/services/user.service.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/server_info.service.dart';
import 'package:mocktail/mocktail.dart';
class MockStoreService extends Mock implements StoreService {}
@@ -20,3 +21,5 @@ class MockPartnerService extends Mock implements PartnerService {}
class MockAssetService extends Mock implements AssetService {}
class MockUserService extends Mock implements UserService {}
class MockServerInfoService extends Mock implements ServerInfoService {}
@@ -5,7 +5,13 @@ import '../../utils.dart';
class RemoteAssetFactory {
const RemoteAssetFactory();
static RemoteAsset create({String? id, String? name, String? ownerId, bool isFavorite = false}) {
static RemoteAsset create({
String? id,
String? name,
String? ownerId,
bool isFavorite = false,
AssetType type = .image,
}) {
id = TestUtils.uuid(id);
return RemoteAsset(
@@ -13,7 +19,7 @@ class RemoteAssetFactory {
name: name ?? 'remote_$id.jpg',
ownerId: TestUtils.uuid(ownerId),
checksum: 'checksum-$id',
type: .image,
type: type,
createdAt: TestUtils.yesterday(),
updatedAt: TestUtils.now(),
isFavorite: isFavorite,
+24
View File
@@ -3,6 +3,8 @@ import 'dart:typed_data';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:mocktail/mocktail.dart' as mock;
@@ -18,6 +20,7 @@ class RepositoryMocks {
final localAlbum = LocalAlbumRepositoryStub(MockLocalAlbumRepository());
final localAsset = LocalAssetRepositoryStub(MockDriftLocalAssetRepository());
final trashedAsset = MockTrashedLocalAssetRepository();
final remoteAsset = RemoteAssetRepositoryStub(MockRemoteAssetRepository());
final nativeApi = NativeSyncApiStub(MockNativeSyncApi());
@@ -30,10 +33,12 @@ class RepositoryMocks {
localAlbum.reset();
localAsset.reset();
reset(trashedAsset);
remoteAsset.reset();
nativeApi.reset();
_stubLocalAlbumRepository();
_stubLocalAssetRepository();
_stubNativeSyncApi();
_stubRemoteAssetRepository();
}
void _stubLocalAlbumRepository() {
@@ -49,6 +54,11 @@ class RepositoryMocks {
void _stubNativeSyncApi() {
when(nativeApi.hashAssets).thenAnswer((_) async => []);
}
void _stubRemoteAssetRepository() {
when(remoteAsset.getAssetEdits).thenAnswer((_) async => []);
when(remoteAsset.getExif).thenAnswer((_) async => null);
}
}
class ServiceMocks {
@@ -89,6 +99,7 @@ class ServiceMocks {
void _stubAssetService() {
when(asset.updateFavorite).thenAnswer((_) async {});
when(asset.applyEdits).thenAnswer((_) async {});
}
}
@@ -96,6 +107,7 @@ void _registerFallbacks() {
registerFallbackValue(LocalAlbumFactory.create());
registerFallbackValue(LocalAssetFactory.create());
registerFallbackValue(Uint8List(0));
registerFallbackValue(<AssetEdit>[]);
}
extension type const Stub<T extends Mock>(T mockedClass) {
@@ -119,6 +131,15 @@ extension type const LocalAssetRepositoryStub(MockDriftLocalAssetRepository repo
() => repo.updateHashes(any());
}
extension type const RemoteAssetRepositoryStub(MockRemoteAssetRepository repo)
implements Stub<MockRemoteAssetRepository> {
Future<List<AssetEdit>> Function() get getAssetEdits =>
() => repo.getAssetEdits(any());
Future<ExifInfo?> Function() get getExif =>
() => repo.getExif(any());
}
extension type const PartnerServiceStub(MockPartnerService service) implements Stub<MockPartnerService> {
Stream<Iterable<User>> Function() get getCandidates =>
() => service.getCandidates(any());
@@ -167,6 +188,9 @@ extension type const UserServiceStub(MockUserService service) implements Stub<Mo
extension type const AssetServiceStub(MockAssetService service) implements Stub<MockAssetService> {
Future<void> Function() get updateFavorite =>
() => service.updateFavorite(any(), any());
Future<void> Function() get applyEdits =>
() => service.applyEdits(any(), any());
}
extension type const NativeSyncApiStub(MockNativeSyncApi api) implements Stub<MockNativeSyncApi> {
@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/models/server_info/server_version.model.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/edit.action.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:mocktail/mocktail.dart';
import '../../../domain/service.mock.dart';
import '../../../infrastructure/repository.mock.dart';
import '../../factories/remote_asset_factory.dart';
import '../../riverpod_mocks.dart';
import '../presentation_context.dart';
void main() {
late PresentationContext context;
late MockRemoteAssetRepository remoteAssetRepo;
late MockAssetService assetService;
const supportedVersion = ServerVersion(major: 2, minor: 6, patch: 0);
const unsupportedVersion = ServerVersion(major: 2, minor: 5, patch: 9);
setUp(() async {
context = await PresentationContext.create();
remoteAssetRepo = context.repository.remoteAsset.repo;
assetService = context.service.asset.service;
});
tearDown(() {
context.dispose();
});
List<Override> overrides(ServerVersion version) => [
serverInfoProvider.overrideWith((ref) => FakeServerInfoNotifier(version)),
];
RemoteAsset owned({AssetType type = AssetType.image}) =>
RemoteAssetFactory.create(ownerId: context.currentUser.id, type: type);
Future<void> pumpAction(WidgetTester tester, EditAssetAction action, {ServerVersion version = supportedVersion}) =>
tester.pumpTestWidget(context, ActionIconButtonWidget(action: action), overrides: overrides(version));
group('EditAssetAction', () {
testWidgets('visible for a single owned editable asset on a supported server', (tester) async {
await pumpAction(tester, EditAssetAction(assets: [owned()]));
expect(find.byType(ImmichIconButton), findsOneWidget);
});
testWidgets('hidden when the server is older than 2.6.0', (tester) async {
await pumpAction(tester, EditAssetAction(assets: [owned()]), version: unsupportedVersion);
expect(find.byType(ImmichIconButton), findsNothing);
});
testWidgets('hidden for more than one asset', (tester) async {
await pumpAction(tester, EditAssetAction(assets: [owned(), owned()]));
expect(find.byType(ImmichIconButton), findsNothing);
});
testWidgets('hidden for an asset owned by someone else', (tester) async {
await pumpAction(tester, EditAssetAction(assets: [RemoteAssetFactory.create()]));
expect(find.byType(ImmichIconButton), findsNothing);
});
testWidgets('hidden for a non-editable asset', (tester) async {
await pumpAction(tester, EditAssetAction(assets: [owned(type: AssetType.video)]));
expect(find.byType(ImmichIconButton), findsNothing);
});
});
group('EditAssetAction onAction', () {
testWidgets('reads the edits and exif for the asset from the repository', (tester) async {
final asset = owned();
await tester.pumpTestAction(context, EditAssetAction(assets: [asset]), overrides: overrides(supportedVersion));
await tester.pumpAndSettle();
verify(() => remoteAssetRepo.getAssetEdits(asset.id)).called(1);
verify(() => remoteAssetRepo.getExif(asset.id)).called(1);
});
testWidgets('applyEdits forwards the edits to the service and waits for both ready events', (tester) async {
late FakeWebsocketNotifier websocket;
const edits = <AssetEdit>[];
late WidgetRef capturedRef;
await tester.pumpTestWidget(
context,
Consumer(
builder: (_, ref, _) {
capturedRef = ref;
return const SizedBox.shrink();
},
),
overrides: [websocketProvider.overrideWith((ref) => websocket = FakeWebsocketNotifier(ref))],
);
await applyEdits(capturedRef, 'asset-1', edits);
verify(() => assetService.applyEdits('asset-1', edits)).called(1);
expect(websocket.waitedEvents, containsAll(['AssetEditReadyV1', 'AssetEditReadyV2']));
});
});
}
@@ -43,6 +43,7 @@ class PresentationContext {
currentUserProvider.overrideWith((ref) => CurrentUserProvider(service.user.service)),
assetServiceProvider.overrideWithValue(service.asset.service),
partnerServiceProvider.overrideWithValue(service.partner.service),
remoteAssetRepositoryProvider.overrideWithValue(repository.remoteAsset.repo),
];
static Future<PresentationContext> create() async {
+24
View File
@@ -0,0 +1,24 @@
import 'package:immich_mobile/models/server_info/server_version.model.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import '../domain/service.mock.dart';
class FakeServerInfoNotifier extends ServerInfoNotifier {
FakeServerInfoNotifier([ServerVersion version = const ServerVersion(major: 2, minor: 6, patch: 0)])
: super(MockServerInfoService()) {
state = state.copyWith(serverVersion: version);
}
}
class FakeWebsocketNotifier extends WebsocketNotifier {
FakeWebsocketNotifier(super.ref);
final List<String> waitedEvents = [];
@override
Future<void> waitForEvent(String event, bool Function(dynamic)? predicate, Duration timeout) {
waitedEvents.add(event);
return Future.value();
}
}
+33 -28
View File
@@ -7,17 +7,18 @@ from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."fileCreatedAt" >= $1
and "asset_exif"."lensModel" = $2
and "asset"."ownerId" = any ($3::uuid[])
and "asset"."isFavorite" = $4
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
and "asset"."deletedAt" is null
order by
"asset"."fileCreatedAt" desc
limit
$5
offset
$6
offset
$7
-- SearchRepository.searchStatistics
select
@@ -26,10 +27,11 @@ from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."fileCreatedAt" >= $1
and "asset_exif"."lensModel" = $2
and "asset"."ownerId" = any ($3::uuid[])
and "asset"."isFavorite" = $4
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
and "asset"."deletedAt" is null
-- SearchRepository.searchRandom
@@ -39,15 +41,16 @@ from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."fileCreatedAt" >= $1
and "asset_exif"."lensModel" = $2
and "asset"."ownerId" = any ($3::uuid[])
and "asset"."isFavorite" = $4
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
and "asset"."deletedAt" is null
order by
random()
limit
$5
$6
-- SearchRepository.searchLargeAssets
select
@@ -57,16 +60,17 @@ from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."fileCreatedAt" >= $1
and "asset_exif"."lensModel" = $2
and "asset"."ownerId" = any ($3::uuid[])
and "asset"."isFavorite" = $4
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
and "asset"."deletedAt" is null
and "asset_exif"."fileSizeInByte" > $5
and "asset_exif"."fileSizeInByte" > $6
order by
"asset_exif"."fileSizeInByte" desc
limit
$6
$7
-- SearchRepository.searchSmart
begin
@@ -79,17 +83,18 @@ from
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
inner join "smart_search" on "asset"."id" = "smart_search"."assetId"
where
"asset"."fileCreatedAt" >= $1
and "asset_exif"."lensModel" = $2
and "asset"."ownerId" = any ($3::uuid[])
and "asset"."isFavorite" = $4
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
and "asset"."deletedAt" is null
order by
smart_search.embedding <=> $5
smart_search.embedding <=> $6
limit
$6
offset
$7
offset
$8
commit
-- SearchRepository.getEmbedding
@@ -53,10 +53,10 @@ export interface ImmichTags extends Omit<Tags, TagsWithWrongTypes> {
RegionList: {
Area: {
// (X,Y) // center of the rectangle
X: number | string;
Y: number | string;
W: number | string;
H: number | string;
X: number;
Y: number;
W: number;
H: number;
Unit: string;
};
Rotation?: number;
+3 -4
View File
@@ -117,8 +117,7 @@ type BaseAssetSearchOptions = SearchDateOptions &
SearchAlbumOptions &
SearchOcrOptions;
export type AssetSearchOptions = Omit<BaseAssetSearchOptions, 'visibility'> &
SearchRelationOptions & { visibility?: AssetVisibility | 'not-locked' };
export type AssetSearchOptions = BaseAssetSearchOptions & SearchRelationOptions;
export type AssetSearchBuilderOptions = Omit<AssetSearchOptions, 'orderDirection'>;
@@ -126,11 +125,11 @@ export type SmartSearchOptions = SearchDateOptions &
SearchEmbeddingOptions &
SearchExifOptions &
SearchOneToOneRelationOptions &
Omit<SearchStatusOptions, 'visibility'> &
SearchStatusOptions &
SearchUserIdOptions &
SearchPeopleOptions &
SearchTagOptions &
SearchOcrOptions & { visibility?: AssetVisibility | 'not-locked' };
SearchOcrOptions;
export type OcrSearchOptions = SearchDateOptions & SearchOcrOptions;
+1 -33
View File
@@ -39,10 +39,7 @@ const forSidecarJob = (
};
};
const makeFaceTags = (
face: Partial<{ Name: string }> = {},
orientation?: ImmichTags['Orientation'],
): Partial<ImmichTags> => ({
const makeFaceTags = (face: Partial<{ Name: string }> = {}, orientation?: ImmichTags['Orientation']) => ({
Orientation: orientation,
RegionInfo: {
AppliedToDimensions: { W: 1000, H: 100, Unit: 'pixel' },
@@ -1374,35 +1371,6 @@ describe(MetadataService.name, () => {
expect(mocks.person.updateAll).not.toHaveBeenCalled();
});
it('should handle string coordinates in face region bounding box calculation by limiting to 16 decimal places', async () => {
const asset = AssetFactory.create();
const person = PersonFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
const faceTags = makeFaceTags({ Name: person.name });
// Simulating EXIF returning a string with >16 decimal places
faceTags.RegionInfo!.RegionList[0].Area.X = '0.48564814814814824';
faceTags.RegionInfo!.RegionList[0].Area.W = '0.2';
mockReadTags(faceTags);
mocks.person.getDistinctNames.mockResolvedValue([]);
mocks.person.createAll.mockResolvedValue([person.id]);
mocks.person.update.mockResolvedValue(person);
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.person.refreshFaces).toHaveBeenCalledWith(
[
expect.objectContaining({
boundingBoxX1: Math.floor((0.485_648_148_148_148_2 - 0.2 / 2) * 1000),
}),
],
[],
);
});
it('should apply metadata face tags creating new people', async () => {
const asset = AssetFactory.create();
const person = PersonFactory.create();
+4 -16
View File
@@ -854,13 +854,6 @@ export class MetadataService extends BaseService {
// update area coordinates and dimensions in RegionList assuming "normalized" unit as per MWG guidelines
const adjustedRegionList = regionInfo.RegionList.map((region) => {
let { X, Y, W, H } = region.Area;
// EXIF floats with >16 decimals are serialized as strings. Ensure they are numbers.
X = Number(X);
Y = Number(Y);
W = Number(W);
H = Number(H);
switch (orientation) {
case ExifOrientation.MirrorHorizontal: {
X = 1 - X;
@@ -933,21 +926,16 @@ export class MetadataService extends BaseService {
const loweredName = region.Name.toLowerCase();
const personId = existingNameMap.get(loweredName) || this.cryptoRepository.randomUUID();
const X = Number(region.Area.X);
const Y = Number(region.Area.Y);
const W = Number(region.Area.W);
const H = Number(region.Area.H);
const face = {
id: this.cryptoRepository.randomUUID(),
personId,
assetId: asset.id,
imageWidth,
imageHeight,
boundingBoxX1: Math.floor((X - W / 2) * imageWidth),
boundingBoxY1: Math.floor((Y - H / 2) * imageHeight),
boundingBoxX2: Math.floor((X + W / 2) * imageWidth),
boundingBoxY2: Math.floor((Y + H / 2) * imageHeight),
boundingBoxX1: Math.floor((region.Area.X - region.Area.W / 2) * imageWidth),
boundingBoxY1: Math.floor((region.Area.Y - region.Area.H / 2) * imageHeight),
boundingBoxX2: Math.floor((region.Area.X + region.Area.W / 2) * imageWidth),
boundingBoxY2: Math.floor((region.Area.Y + region.Area.H / 2) * imageHeight),
sourceType: SourceType.Exif,
};
+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], visibility: 'not-locked' },
{ query: 'test', embedding: '[1, 2, 3]', userIds: [authStub.user1.user.id] },
);
});
+3 -24
View File
@@ -73,22 +73,14 @@ 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,
},
@@ -99,13 +91,9 @@ 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,
});
}
@@ -126,11 +114,7 @@ export class SearchService extends BaseService {
}
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
const items = await this.searchRepository.searchLargeAssets(dto.size || 250, {
...dto,
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
userIds,
});
const items = await this.searchRepository.searchLargeAssets(dto.size || 250, { ...dto, userIds });
return items.map((item) => mapAsset(item, { auth }));
}
@@ -171,12 +155,7 @@ export class SearchService extends BaseService {
const size = dto.size || 100;
const { hasNextPage, items } = await this.searchRepository.searchSmart(
{ page, size },
{
...dto,
userIds: await userIds,
embedding,
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
},
{ ...dto, userIds: await userIds, embedding },
);
return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth });
+2 -5
View File
@@ -373,15 +373,12 @@ 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')
.$if(!!options.visibility, (qb) =>
options.visibility === 'not-locked'
? qb.where('asset.visibility', '!=', AssetVisibility.Locked)
: qb.where('asset.visibility', '=', options.visibility!),
)
.where('asset.visibility', '=', 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,6 +1,5 @@
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';
@@ -109,71 +108,6 @@ 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; hasElevatedPermission?: boolean };
session?: { id: string };
user?: Omit<
Partial<UserAdmin>,
'createdAt' | 'updatedAt' | 'deletedAt' | 'fileCreatedAt' | 'fileModifiedAt' | 'localDateTime' | 'profileChangedAt'
@@ -46,8 +46,8 @@ const authFactory = ({
if (session) {
auth.session = {
id: session.id ?? newUuid(),
hasElevatedPermission: session.hasElevatedPermission ?? false,
id: session.id,
hasElevatedPermission: false,
};
}