mirror of
https://github.com/immich-app/immich.git
synced 2026-06-29 17:54:35 -07:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 18fec31c1e | |||
| 57eee0148c | |||
| 880f8e2e4c | |||
| 3f67c400d2 | |||
| 5f5ce12041 | |||
| 1c3266e8f2 | |||
| 912484dcdd |
@@ -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 {
|
||||
|
||||
@@ -52,8 +52,6 @@ class RemoteAsset extends BaseAsset {
|
||||
|
||||
bool get isTrashed => deletedAt != null;
|
||||
|
||||
bool get isStacked => stackId != null;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''Asset {
|
||||
|
||||
@@ -138,7 +138,9 @@ class LocalSyncService {
|
||||
final Stopwatch stopwatch = Stopwatch()..start();
|
||||
|
||||
final deviceAlbums = await _nativeSyncApi.getAlbums();
|
||||
final getAlbumsTime = stopwatch.elapsedMilliseconds;
|
||||
final dbAlbums = await _localAlbumRepository.getAll(sortBy: {SortLocalAlbumsBy.id});
|
||||
final getAllTime = stopwatch.elapsedMilliseconds;
|
||||
|
||||
await diffSortedLists(
|
||||
dbAlbums,
|
||||
@@ -148,10 +150,15 @@ class LocalSyncService {
|
||||
onlyFirst: removeAlbum,
|
||||
onlySecond: addAlbum,
|
||||
);
|
||||
final diffTime = stopwatch.elapsedMilliseconds;
|
||||
|
||||
await _nativeSyncApi.checkpointSync();
|
||||
stopwatch.stop();
|
||||
_log.info("Full device sync took - ${stopwatch.elapsedMilliseconds}ms");
|
||||
_log.info(
|
||||
"Full device sync took - ${stopwatch.elapsedMilliseconds}ms "
|
||||
"(getAlbums=${getAlbumsTime}ms, getAll=${getAllTime - getAlbumsTime}ms, "
|
||||
"diff=${diffTime - getAllTime}ms, checkpoint=${stopwatch.elapsedMilliseconds - diffTime}ms)",
|
||||
);
|
||||
} on PlatformException catch (e, s) {
|
||||
if (e.code == _kSyncCancelledCode) {
|
||||
_log.warning("Full device sync cancelled");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
@@ -66,15 +67,16 @@ class LogService {
|
||||
}
|
||||
|
||||
void _handleLogRecord(LogRecord r) {
|
||||
final int isolateHash = Isolate.current.hashCode;
|
||||
dPrint(
|
||||
() =>
|
||||
'[${r.level.name}] [${r.time}] [${r.loggerName}] ${r.message}'
|
||||
'[${r.level.name}] [${r.time}] [${r.loggerName}] [$isolateHash] ${r.message}'
|
||||
'${r.error == null ? '' : '\nError: ${r.error}'}'
|
||||
'${r.stackTrace == null ? '' : '\nStack: ${r.stackTrace}'}',
|
||||
);
|
||||
|
||||
final record = LogMessage(
|
||||
message: r.message,
|
||||
message: '[$isolateHash] ${r.message}',
|
||||
level: r.level.toLogLevel(),
|
||||
createdAt: r.time,
|
||||
logger: r.loggerName,
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
||||
import 'package:immich_mobile/utils/async_mutex.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
typedef TimelineAssetSource = Future<List<BaseAsset>> Function(int index, int count);
|
||||
|
||||
@@ -90,6 +91,7 @@ class TimelineFactory {
|
||||
}
|
||||
|
||||
class TimelineService {
|
||||
static final Logger _log = Logger('TimelineService');
|
||||
final TimelineAssetSource _assetSource;
|
||||
final TimelineBucketSource _bucketSource;
|
||||
final TimelineOrigin origin;
|
||||
@@ -105,34 +107,49 @@ class TimelineService {
|
||||
: this._(assetSource: query.assetSource, bucketSource: query.bucketSource, origin: query.origin);
|
||||
|
||||
TimelineService._({required this._assetSource, required this._bucketSource, required this.origin}) {
|
||||
_bucketSubscription = _bucketSource().listen((buckets) {
|
||||
_mutex.run(() async {
|
||||
final totalAssets = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
|
||||
_bucketSubscription = _bucketSource().listen(
|
||||
(buckets) {
|
||||
_mutex.run(() async {
|
||||
try {
|
||||
final totalAssets = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
|
||||
|
||||
if (totalAssets == 0) {
|
||||
_bufferOffset = 0;
|
||||
_buffer = [];
|
||||
} else {
|
||||
final int offset;
|
||||
final int count;
|
||||
// When the buffer is empty or the old bufferOffset is greater than the new total assets,
|
||||
// we need to reset the buffer and load the first batch of assets.
|
||||
if (_bufferOffset >= totalAssets || _buffer.isEmpty) {
|
||||
offset = 0;
|
||||
count = kTimelineAssetLoadBatchSize;
|
||||
} else {
|
||||
offset = _bufferOffset;
|
||||
count = math.min(_buffer.length, totalAssets - _bufferOffset);
|
||||
_log.info(
|
||||
'[$origin] bucket emission: ${buckets.length} buckets / $totalAssets assets '
|
||||
'(current _totalAssets=$_totalAssets, _bufferOffset=$_bufferOffset, _buffer=${_buffer.length})',
|
||||
);
|
||||
|
||||
if (totalAssets == 0) {
|
||||
_bufferOffset = 0;
|
||||
_buffer = [];
|
||||
} else {
|
||||
final int offset;
|
||||
final int count;
|
||||
// When the buffer is empty or the old bufferOffset is greater than the new total assets,
|
||||
// we need to reset the buffer and load the first batch of assets.
|
||||
if (_bufferOffset >= totalAssets || _buffer.isEmpty) {
|
||||
offset = 0;
|
||||
count = kTimelineAssetLoadBatchSize;
|
||||
} else {
|
||||
offset = _bufferOffset;
|
||||
count = math.min(_buffer.length, totalAssets - _bufferOffset);
|
||||
}
|
||||
_buffer = await _assetSource(offset, count);
|
||||
_bufferOffset = offset;
|
||||
_log.info('[$origin] buffer reloaded: offset=$offset requested=$count got=${_buffer.length}');
|
||||
}
|
||||
|
||||
_totalAssets = totalAssets;
|
||||
EventStream.shared.emit(const TimelineReloadEvent());
|
||||
} catch (error, stack) {
|
||||
_log.severe('[$origin] bucket reload FAILED — _totalAssets stuck at $_totalAssets', error, stack);
|
||||
rethrow;
|
||||
}
|
||||
_buffer = await _assetSource(offset, count);
|
||||
_bufferOffset = offset;
|
||||
}
|
||||
|
||||
// change the state's total assets count only after the buffer is reloaded
|
||||
_totalAssets = totalAssets;
|
||||
EventStream.shared.emit(const TimelineReloadEvent());
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
onError: (Object error, StackTrace stack) {
|
||||
_log.severe('[$origin] bucket stream errored', error, stack);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Stream<List<Bucket>> Function() get watchBuckets => _bucketSource;
|
||||
@@ -164,6 +181,13 @@ class TimelineService {
|
||||
_buffer = await _assetSource(start, len);
|
||||
_bufferOffset = start;
|
||||
|
||||
if (!hasRange(index, count)) {
|
||||
_log.warning(
|
||||
'[$origin] _loadAssets($index, $count): buffer loaded (offset=$start, got=${_buffer.length}) but still '
|
||||
'out of range — _totalAssets=$_totalAssets. getAssets is about to throw RangeError.',
|
||||
);
|
||||
}
|
||||
|
||||
return getAssets(index, count);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,29 +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) {
|
||||
final owned = AssetFilter(assets).owned(scope.authUser.id);
|
||||
if (favorite) {
|
||||
return owned.notFavorites();
|
||||
} else {
|
||||
return owned.favorites();
|
||||
}
|
||||
}
|
||||
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;
|
||||
@@ -35,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);
|
||||
|
||||
@@ -23,6 +23,7 @@ import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.da
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class FixedSegment extends Segment {
|
||||
final double tileHeight;
|
||||
@@ -90,6 +91,7 @@ class FixedSegment extends Segment {
|
||||
}
|
||||
|
||||
class _FixedSegmentRow extends ConsumerWidget {
|
||||
static final Logger _log = Logger('TimelineRow');
|
||||
final int assetIndex;
|
||||
final int assetCount;
|
||||
final double tileHeight;
|
||||
@@ -109,8 +111,20 @@ class _FixedSegmentRow extends ConsumerWidget {
|
||||
final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
final isDynamicLayout = columnCount <= (context.isMobile ? 2 : 3);
|
||||
final inRange = timelineService.hasRange(assetIndex, assetCount);
|
||||
|
||||
if (timelineService.hasRange(assetIndex, assetCount)) {
|
||||
if (assetIndex == 0) {
|
||||
_log.info(
|
||||
'row[0] inRange=$inRange isScrubbing=$isScrubbing totalAssets=${timelineService.totalAssets} '
|
||||
'branch=${inRange
|
||||
? "assets"
|
||||
: isScrubbing
|
||||
? "placeholder(scrubbing)"
|
||||
: "future(load)"}',
|
||||
);
|
||||
}
|
||||
|
||||
if (inRange) {
|
||||
return _buildAssetRow(
|
||||
context,
|
||||
timelineService.getAssets(assetIndex, assetCount),
|
||||
@@ -129,6 +143,13 @@ class _FixedSegmentRow extends ConsumerWidget {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return _buildPlaceholder(context);
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
_log.warning(
|
||||
'render row loadAssets($assetIndex, $assetCount) failed (totalAssets=${timelineService.totalAssets})',
|
||||
snapshot.error,
|
||||
snapshot.stackTrace,
|
||||
);
|
||||
}
|
||||
return _buildAssetRow(context, snapshot.requireData, timelineService, isDynamicLayout);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/utils/debounce.dart';
|
||||
import 'package:intl/intl.dart' hide TextDirection;
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
|
||||
/// for quick navigation of the BoxScrollView.
|
||||
@@ -84,6 +85,7 @@ List<_Segment> _buildSegments({required List<Segment> layoutSegments, required d
|
||||
}
|
||||
|
||||
class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixin {
|
||||
static final Logger _log = Logger('Scrubber');
|
||||
String? _lastLabel;
|
||||
double _thumbTopOffset = 0.0;
|
||||
bool _isDragging = false;
|
||||
@@ -114,6 +116,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_log.info('Scrubber initState');
|
||||
_isDragging = false;
|
||||
_segments = _buildSegments(layoutSegments: widget.layoutSegments, timelineHeight: _scrubberHeight);
|
||||
_thumbAnimationController = AnimationController(vsync: this, duration: kTimelineScrubberFadeInDuration);
|
||||
@@ -134,7 +137,10 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
||||
void didUpdateWidget(covariant Scrubber oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
if (oldWidget.layoutSegments.lastOrNull?.endOffset != widget.layoutSegments.lastOrNull?.endOffset) {
|
||||
final oldEnd = oldWidget.layoutSegments.lastOrNull?.endOffset;
|
||||
final newEnd = widget.layoutSegments.lastOrNull?.endOffset;
|
||||
if (oldEnd != newEnd) {
|
||||
_log.info('Scrubber layoutSegments endOffset $oldEnd -> $newEnd (isDragging=$_isDragging)');
|
||||
_segments = _buildSegments(layoutSegments: widget.layoutSegments, timelineHeight: _scrubberHeight);
|
||||
_monthCount = getMonthCount();
|
||||
}
|
||||
@@ -142,6 +148,15 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_isDragging || _currentScrubberDate != null || _scrubberDebouncer != null) {
|
||||
_log.warning(
|
||||
'Scrubber dispose mid-scrub '
|
||||
'(isDragging=$_isDragging, pendingDate=$_currentScrubberDate, '
|
||||
'debouncerPending=${_scrubberDebouncer != null}) — scrubbing reset may be orphaned',
|
||||
);
|
||||
} else {
|
||||
_log.info('Scrubber dispose');
|
||||
}
|
||||
_thumbAnimationController.dispose();
|
||||
_labelAnimationController.dispose();
|
||||
_fadeOutTimer?.cancel();
|
||||
@@ -208,6 +223,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
||||
}
|
||||
|
||||
void _onDragStart(DragStartDetails _) {
|
||||
_log.info('scrub dragStart');
|
||||
setState(() {
|
||||
_isDragging = true;
|
||||
_labelAnimationController.forward();
|
||||
@@ -222,9 +238,15 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
||||
}
|
||||
|
||||
if (_scrubberHeight <= 0) {
|
||||
_log.warning('drag ignored: scrubberHeight=$_scrubberHeight <= 0');
|
||||
return;
|
||||
}
|
||||
|
||||
final maxScrollExtent = _scrollController.hasClients ? _scrollController.position.maxScrollExtent : -1;
|
||||
if (maxScrollExtent <= 0) {
|
||||
_log.warning('drag ineffective: hasClients=${_scrollController.hasClients} maxScrollExtent=$maxScrollExtent');
|
||||
}
|
||||
|
||||
if (_thumbAnimationController.status != AnimationStatus.forward) {
|
||||
_thumbAnimationController.forward();
|
||||
}
|
||||
@@ -344,6 +366,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
||||
}
|
||||
|
||||
void _onDragEnd(DragEndDetails _) {
|
||||
_log.info('scrub dragEnd -> setScrubbing(false)');
|
||||
_labelAnimationController.reverse();
|
||||
setState(() {
|
||||
_isDragging = false;
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment_builde
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class TimelineArgs {
|
||||
final double maxWidth;
|
||||
@@ -71,14 +72,27 @@ class TimelineState {
|
||||
}
|
||||
|
||||
class TimelineStateNotifier extends Notifier<TimelineState> {
|
||||
static final Logger _log = Logger('TimelineState');
|
||||
|
||||
void setScrubbing(bool isScrubbing) {
|
||||
if (state.isScrubbing != isScrubbing) {
|
||||
_log.info('isScrubbing ${state.isScrubbing} -> $isScrubbing (from ${_callSite()})');
|
||||
}
|
||||
state = state.copyWith(isScrubbing: isScrubbing);
|
||||
}
|
||||
|
||||
void setScrolling(bool isScrolling) {
|
||||
if (state.isScrolling != isScrolling) {
|
||||
_log.info('isScrolling ${state.isScrolling} -> $isScrolling (from ${_callSite()})');
|
||||
}
|
||||
state = state.copyWith(isScrolling: isScrolling);
|
||||
}
|
||||
|
||||
static String _callSite() {
|
||||
final frames = StackTrace.current.toString().split('\n');
|
||||
return frames.length > 2 ? frames[2].trim() : 'unknown';
|
||||
}
|
||||
|
||||
@override
|
||||
TimelineState build() => const TimelineState(isScrubbing: false, isScrolling: false);
|
||||
}
|
||||
@@ -96,6 +110,11 @@ final timelineSegmentProvider = StreamProvider.autoDispose<List<Segment>>((ref)
|
||||
|
||||
final timelineService = ref.watch(timelineServiceProvider);
|
||||
yield* timelineService.watchBuckets().map((buckets) {
|
||||
final layoutTotal = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
|
||||
Logger('TimelineService').info(
|
||||
'[${timelineService.origin}] segment layout: '
|
||||
'${buckets.length} buckets / $layoutTotal assets (service.totalAssets=${timelineService.totalAssets})',
|
||||
);
|
||||
return FixedSegmentBuilder(
|
||||
buckets: buckets,
|
||||
tileHeight: tileExtent,
|
||||
|
||||
@@ -28,6 +28,7 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
|
||||
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
|
||||
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class Timeline extends StatelessWidget {
|
||||
const Timeline({
|
||||
@@ -136,6 +137,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
static final Logger _log = Logger('Timeline');
|
||||
late final ScrollController _scrollController;
|
||||
StreamSubscription? _eventSubscription;
|
||||
|
||||
@@ -153,6 +155,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_log.info('SliverTimeline initState');
|
||||
_scrollController = ScrollController(onAttach: _restoreAssetPosition);
|
||||
_eventSubscription = EventStream.shared.listen(_onEvent);
|
||||
|
||||
@@ -179,6 +182,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
}
|
||||
|
||||
void _onEvent(Event event) {
|
||||
_log.info('event ${event.runtimeType}');
|
||||
switch (event) {
|
||||
case ScrollToTopEvent():
|
||||
{
|
||||
@@ -186,7 +190,10 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
timelineState.setScrubbing(true);
|
||||
_scrollController
|
||||
.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut)
|
||||
.whenComplete(() => timelineState.setScrubbing(false));
|
||||
.whenComplete(() {
|
||||
_log.info('ScrollToTop animation done -> setScrubbing(false)');
|
||||
timelineState.setScrubbing(false);
|
||||
});
|
||||
}
|
||||
|
||||
case ScrollToDateEvent scrollToDateEvent:
|
||||
@@ -246,6 +253,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_log.info('SliverTimeline dispose');
|
||||
_scrollController.dispose();
|
||||
_eventSubscription?.cancel();
|
||||
super.dispose();
|
||||
@@ -286,8 +294,12 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
duration: const Duration(milliseconds: 500),
|
||||
curve: Curves.easeInOut,
|
||||
)
|
||||
.whenComplete(() => timelineState.setScrubbing(false));
|
||||
.whenComplete(() {
|
||||
_log.info('ScrollToDate animation done -> setScrubbing(false)');
|
||||
timelineState.setScrubbing(false);
|
||||
});
|
||||
} else {
|
||||
_log.info('ScrollToDate: no matching segment for $date -> setScrubbing(false)');
|
||||
timelineState.setScrubbing(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -101,8 +101,20 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cancelSync() async {
|
||||
final backgroundManager = _ref.read(backgroundSyncProvider);
|
||||
final nativeSync = _ref.read(nativeSyncApiProvider);
|
||||
await Future.wait([
|
||||
nativeSync.cancelSync(),
|
||||
nativeSync.cancelHashing(),
|
||||
backgroundManager.cancel(),
|
||||
]).timeout(const Duration(seconds: 5), onTimeout: () => const <void>[]);
|
||||
}
|
||||
|
||||
Future<void> _handleBetaTimelineResume() async {
|
||||
unawaited(_ref.read(backgroundWorkerLockServiceProvider).lock());
|
||||
_log.info("Handling beta timeline resume");
|
||||
await _cancelSync();
|
||||
|
||||
// Give isolates time to complete any ongoing database transactions
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
@@ -196,6 +208,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
Future<void> _performPause() {
|
||||
if (_ref.read(authProvider).isAuthenticated) {
|
||||
_ref.read(driftBackupProvider.notifier).stopForegroundBackup();
|
||||
unawaited(_cancelSync());
|
||||
|
||||
_ref.read(websocketProvider.notifier).disconnect();
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
final _log = Logger('TimelineProvider');
|
||||
|
||||
final timelineRepositoryProvider = Provider<DriftTimelineRepository>(
|
||||
(ref) => DriftTimelineRepository(ref.watch(driftProvider)),
|
||||
@@ -18,7 +21,11 @@ final timelineServiceProvider = Provider<TimelineService>(
|
||||
(ref) {
|
||||
final timelineUsers = ref.watch(timelineUsersProvider).valueOrNull ?? [];
|
||||
final timelineService = ref.watch(timelineFactoryProvider).main(timelineUsers);
|
||||
ref.onDispose(timelineService.dispose);
|
||||
_log.info('main TimelineService built users=$timelineUsers');
|
||||
ref.onDispose(() {
|
||||
_log.info('main TimelineService disposed');
|
||||
timelineService.dispose();
|
||||
});
|
||||
return timelineService;
|
||||
},
|
||||
// Empty dependencies to inform the framework that this provider
|
||||
@@ -36,8 +43,12 @@ final timelineFactoryProvider = Provider<TimelineFactory>(
|
||||
final timelineUsersProvider = StreamProvider<List<String>>((ref) {
|
||||
final currentUserId = ref.watch(currentUserProvider.select((u) => u?.id));
|
||||
if (currentUserId == null) {
|
||||
_log.info('timelineUsers: currentUserId=null -> []');
|
||||
return Stream.value([]);
|
||||
}
|
||||
|
||||
return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId);
|
||||
return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId).map((users) {
|
||||
_log.info('timelineUsers emission: $users');
|
||||
return users;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,28 +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> favorites() => where(_isFavorite);
|
||||
AssetFilter<T> notFavorites() => whereNot(_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(_hasVisibility(visibility));
|
||||
AssetFilter<RemoteAsset> notVisibility(AssetVisibility visibility) => remote().whereNot(_hasVisibility(visibility));
|
||||
AssetFilter<RemoteAsset> archived() => visibility(.archive);
|
||||
AssetFilter<RemoteAsset> notArchived() => notVisibility(.archive);
|
||||
AssetFilter<RemoteAsset> stacked() => remote().where(_isStacked);
|
||||
AssetFilter<RemoteAsset> notStacked() => remote().whereNot(_isStacked);
|
||||
|
||||
AssetFilter<LocalAsset> local() => AssetFilter(assets.whereType<LocalAsset>());
|
||||
AssetFilter<LocalAsset> backedUp() => local().where(_isBackedUp);
|
||||
}
|
||||
|
||||
bool _isFavorite(BaseAsset asset) => asset.isFavorite;
|
||||
bool _isStacked(RemoteAsset asset) => asset.isStacked;
|
||||
bool _isBackedUp(LocalAsset asset) => asset.remoteAssetId != null;
|
||||
bool Function(RemoteAsset asset) _hasVisibility(AssetVisibility visibility) =>
|
||||
(asset) => asset.visibility == visibility;
|
||||
+4
-4
@@ -366,18 +366,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: drift
|
||||
sha256: "8033500116b24398fba0cca0369cc31678cd627c01e41753a61186911cea743e"
|
||||
sha256: "6cc0b623c0e83f7080524d8396e9301b1d78b9c66a4fdceeb0f798211303254c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.33.0"
|
||||
version: "2.34.0"
|
||||
drift_dev:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: drift_dev
|
||||
sha256: b3dd5b75e30522a91da8abda9f5bb17230cb038097f6d15fa75d42bb563428aa
|
||||
sha256: "9cfff1576b49725da0d32c040651a41ae195e8c4af8d8da301593e41d7abc2f7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.33.0"
|
||||
version: "2.34.0"
|
||||
drift_sqlite_async:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
+2
-2
@@ -19,7 +19,7 @@ dependencies:
|
||||
crypto: ^3.0.7
|
||||
device_info_plus: ^12.4.0
|
||||
diacritic: ^0.1.6
|
||||
drift: ^2.32.1
|
||||
drift: ^2.34.0
|
||||
drift_sqlite_async: 0.3.1
|
||||
dynamic_color: ^1.8.1
|
||||
easy_localization: ^3.0.8
|
||||
@@ -96,7 +96,7 @@ dev_dependencies:
|
||||
auto_route_generator: ^10.5.0
|
||||
build_runner: ^2.13.1
|
||||
# Drift generator
|
||||
drift_dev: ^2.32.1
|
||||
drift_dev: ^2.34.0
|
||||
fake_async: ^1.3.3
|
||||
file: ^7.0.1 # for MemoryFileSystem
|
||||
flutter_launcher_icons: ^0.14.4
|
||||
|
||||
@@ -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]).favorites();
|
||||
|
||||
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]).notArchived();
|
||||
|
||||
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]).notStacked();
|
||||
|
||||
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]).notFavorites();
|
||||
|
||||
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).notArchived().notStacked();
|
||||
|
||||
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).favorites();
|
||||
|
||||
expect(result.toList(), [favorite]);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user