mirror of
https://github.com/immich-app/immich.git
synced 2026-06-30 18:17:06 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb8e242fbe |
@@ -5,3 +5,4 @@
|
||||
/machine-learning/ @mertalev
|
||||
/e2e/ @danieldietzler
|
||||
/mobile/ @shenlong-tanwen @santoshakil
|
||||
/native/ @santoshakil @mertalev
|
||||
|
||||
@@ -1461,7 +1461,6 @@
|
||||
"never": "Never",
|
||||
"new_album": "New Album",
|
||||
"new_api_key": "New API Key",
|
||||
"new_feature": "New Feature",
|
||||
"new_password": "New password",
|
||||
"new_person": "New person",
|
||||
"new_pin_code": "New PIN code",
|
||||
@@ -1522,8 +1521,6 @@
|
||||
"obtainium_configurator": "Obtainium Configurator",
|
||||
"obtainium_configurator_instructions": "Use Obtainium to install and update the Android app directly from Immich GitHub's release. Create an API key and select a variant to create your Obtainium configuration link",
|
||||
"ocr": "OCR",
|
||||
"ocr_body": "Immich now reads the text inside your photos, so you can search for them by what they say.",
|
||||
"ocr_title": "Search text in your photos",
|
||||
"official_immich_resources": "Official Immich Resources",
|
||||
"offline": "Offline",
|
||||
"offset": "Offset",
|
||||
@@ -1542,8 +1539,6 @@
|
||||
"open": "Open",
|
||||
"open_calendar": "Open calendar",
|
||||
"open_in_browser": "Open in browser",
|
||||
"open_in_immich_body": "Set Immich as your gallery on Android to open photos straight from other apps.",
|
||||
"open_in_immich_title": "Open photos in Immich",
|
||||
"open_in_map_view": "Open in map view",
|
||||
"open_in_openstreetmap": "Open in OpenStreetMap",
|
||||
"open_the_search_filters": "Open the search filters",
|
||||
@@ -1702,10 +1697,7 @@
|
||||
"recent": "Recent",
|
||||
"recent_searches": "Recent searches",
|
||||
"recently_added": "Recently added",
|
||||
"recently_added_body": "Jump straight to everything you've added lately on a dedicated page.",
|
||||
"recently_added_description": "Browse your assets sorted by when they were uploaded to Immich",
|
||||
"recently_added_page_title": "Recently Added",
|
||||
"recently_added_title": "Recently added",
|
||||
"recently_taken": "Recently taken",
|
||||
"refresh": "Refresh",
|
||||
"refresh_encoded_videos": "Refresh encoded videos",
|
||||
@@ -1912,8 +1904,6 @@
|
||||
"share_link": "Share Link",
|
||||
"share_original": "Use original (large)",
|
||||
"share_preview": "Use thumbnail (small)",
|
||||
"share_quality_body": "Press and hold the share button to choose the image quality before you share.",
|
||||
"share_quality_title": "Choose your share quality",
|
||||
"shared": "Shared",
|
||||
"shared_album_activities_input_disable": "Comment is disabled",
|
||||
"shared_album_activity_remove_content": "Do you want to delete this activity?",
|
||||
@@ -1995,19 +1985,16 @@
|
||||
"sign_out": "Sign Out",
|
||||
"sign_up": "Sign up",
|
||||
"size": "Size",
|
||||
"skip": "Skip",
|
||||
"skip_to_content": "Skip to content",
|
||||
"skip_to_folders": "Skip to folders",
|
||||
"skip_to_tags": "Skip to tags",
|
||||
"slideshow": "Slideshow",
|
||||
"slideshow_body": "Sit back and watch your photos play in a full-screen slideshow.",
|
||||
"slideshow_metadata_overlay_mode": "Overlay content",
|
||||
"slideshow_metadata_overlay_mode_description_only": "Description only",
|
||||
"slideshow_metadata_overlay_mode_full": "Full",
|
||||
"slideshow_repeat": "Repeat slideshow",
|
||||
"slideshow_repeat_description": "Loop back to beginning when slideshow ends",
|
||||
"slideshow_settings": "Slideshow settings",
|
||||
"slideshow_title": "Slideshow",
|
||||
"smart_album": "Smart album",
|
||||
"some_assets_already_have_a_location_warning": "Some of the selected assets already have a location",
|
||||
"sort_albums_by": "Sort albums by...",
|
||||
@@ -2170,8 +2157,6 @@
|
||||
"upload_status_errors": "Errors",
|
||||
"upload_status_uploaded": "Uploaded",
|
||||
"upload_success": "Upload success, refresh the page to see new upload assets.",
|
||||
"upload_to_album_body": "For users that don't utilize the manual upload feature, you can now choose to add local photos directly into an album as you upload them, no need to upload then add to an album later anymore.",
|
||||
"upload_to_album_title": "Upload straight to an album",
|
||||
"upload_to_immich": "Upload to Immich ({count})",
|
||||
"uploading": "Uploading",
|
||||
"uploading_media": "Uploading media",
|
||||
@@ -2239,9 +2224,6 @@
|
||||
"week": "Week",
|
||||
"welcome": "Welcome",
|
||||
"welcome_to_immich": "Welcome to Immich",
|
||||
"whats_new": "What's new",
|
||||
"whats_new_settings_subtitle": "See what's new in Immich",
|
||||
"whats_new_version": "Version {version}",
|
||||
"when": "When",
|
||||
"width": "Width",
|
||||
"wifi_name": "Wi-Fi Name",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 76 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,33 @@
|
||||
// Plumbing check: proves immich_native_core is usable from the real immich app on
|
||||
// a real device/sim — the build-hook compiled the Rust for this target, the code
|
||||
// asset bundled into the app, and the @Native symbols resolve at runtime.
|
||||
// Self-contained: does NOT boot the immich app or need a server.
|
||||
//
|
||||
// Run: flutter test integration_test/native_core_test.dart -d <device>
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_native_core/immich_native_core.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('native core loads: coreVersion is non-empty', () {
|
||||
expect(coreVersion(), isNotEmpty);
|
||||
});
|
||||
|
||||
test('sha1Hex matches the FIPS-180 vector', () {
|
||||
expect(
|
||||
sha1Hex(Uint8List.fromList(utf8.encode('abc'))),
|
||||
'a9993e364706816aba3e25717850c26c9cd0d89d',
|
||||
);
|
||||
});
|
||||
|
||||
test('rotateRgba8888 (the PR #29337 algorithm) rotates 180', () {
|
||||
// 2x1: red, green -> green, red
|
||||
final src = Uint8List.fromList([255, 0, 0, 255, 0, 255, 0, 255]);
|
||||
expect(rotateRgba8888(src, 8, 2, 1, 3), [0, 255, 0, 255, 255, 0, 0, 255]);
|
||||
});
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/config/album_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/backup_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/cleanup_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/feature_message_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/image_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/map_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/network_config.dart';
|
||||
@@ -17,7 +16,6 @@ import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/settings_key.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
|
||||
const defaultConfig = AppConfig();
|
||||
|
||||
@@ -34,7 +32,6 @@ class AppConfig {
|
||||
final BackupConfig backup;
|
||||
final NetworkConfig network;
|
||||
final ShareConfig share;
|
||||
final FeatureMessageConfig featureMessage;
|
||||
|
||||
const AppConfig({
|
||||
this.logLevel = .info,
|
||||
@@ -49,7 +46,6 @@ class AppConfig {
|
||||
this.backup = const .new(),
|
||||
this.network = const .new(),
|
||||
this.share = const .new(),
|
||||
this.featureMessage = const .new(),
|
||||
});
|
||||
|
||||
AppConfig copyWith({
|
||||
@@ -65,7 +61,6 @@ class AppConfig {
|
||||
BackupConfig? backup,
|
||||
NetworkConfig? network,
|
||||
ShareConfig? share,
|
||||
FeatureMessageConfig? featureMessage,
|
||||
}) => .new(
|
||||
logLevel: logLevel ?? this.logLevel,
|
||||
theme: theme ?? this.theme,
|
||||
@@ -79,7 +74,6 @@ class AppConfig {
|
||||
backup: backup ?? this.backup,
|
||||
network: network ?? this.network,
|
||||
share: share ?? this.share,
|
||||
featureMessage: featureMessage ?? this.featureMessage,
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -97,29 +91,15 @@ class AppConfig {
|
||||
other.album == album &&
|
||||
other.backup == backup &&
|
||||
other.network == network &&
|
||||
other.share == share &&
|
||||
other.featureMessage == featureMessage);
|
||||
other.share == share);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
logLevel,
|
||||
theme,
|
||||
cleanup,
|
||||
map,
|
||||
timeline,
|
||||
image,
|
||||
viewer,
|
||||
slideshow,
|
||||
album,
|
||||
backup,
|
||||
network,
|
||||
share,
|
||||
featureMessage,
|
||||
);
|
||||
int get hashCode =>
|
||||
Object.hash(logLevel, theme, cleanup, map, timeline, image, viewer, slideshow, album, backup, network, share);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'AppConfig(logLevel: $logLevel, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network, share: $share, featureMessage: $featureMessage)';
|
||||
'AppConfig(logLevel: $logLevel, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network, share: $share)';
|
||||
|
||||
T read<T>(SettingsKey<T> key) =>
|
||||
(switch (key) {
|
||||
@@ -166,7 +146,6 @@ class AppConfig {
|
||||
.slideshowDuration => slideshow.duration,
|
||||
.slideshowLook => slideshow.look,
|
||||
.slideshowDirection => slideshow.direction,
|
||||
.featureMessageSeenRelease => featureMessage.seenRelease,
|
||||
})
|
||||
as T;
|
||||
|
||||
@@ -220,7 +199,6 @@ class AppConfig {
|
||||
.slideshowDuration => copyWith(slideshow: slideshow.copyWith(duration: value as int)),
|
||||
.slideshowLook => copyWith(slideshow: slideshow.copyWith(look: value as SlideshowLook)),
|
||||
.slideshowDirection => copyWith(slideshow: slideshow.copyWith(direction: value as SlideshowDirection)),
|
||||
.featureMessageSeenRelease => copyWith(featureMessage: featureMessage.copyWith(seenRelease: value as SemVer)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
|
||||
class FeatureMessageConfig {
|
||||
final SemVer seenRelease;
|
||||
|
||||
const FeatureMessageConfig({this.seenRelease = const SemVer(major: 0, minor: 0, patch: 0)});
|
||||
|
||||
FeatureMessageConfig copyWith({SemVer? seenRelease}) =>
|
||||
FeatureMessageConfig(seenRelease: seenRelease ?? this.seenRelease);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) || (other is FeatureMessageConfig && other.seenRelease == seenRelease);
|
||||
|
||||
@override
|
||||
int get hashCode => seenRelease.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => 'FeatureMessageConfig(seenRelease: $seenRelease)';
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
|
||||
class FeatureHighlight {
|
||||
/// Asset path of the feature screenshot, or null to show a placeholder.
|
||||
final String? image;
|
||||
final String titleKey;
|
||||
final String bodyKey;
|
||||
final List<TargetPlatform> platform;
|
||||
|
||||
const FeatureHighlight({
|
||||
this.image,
|
||||
required this.titleKey,
|
||||
required this.bodyKey,
|
||||
this.platform = const [.iOS, .android],
|
||||
});
|
||||
|
||||
bool get isVisibleOnCurrentPlatform => platform.contains(defaultTargetPlatform);
|
||||
}
|
||||
|
||||
/// The release this batch of highlights was authored for. Content-defined:
|
||||
/// bump it only when publishing a new batch, never from the running app version.
|
||||
const featureMessageRelease = SemVer(major: 3, minor: 0, patch: 0);
|
||||
|
||||
/// Highlights relevant to the current platform.
|
||||
List<FeatureHighlight> get visibleFeatureMessageHighlights =>
|
||||
featureMessageHighlights.where((h) => h.isVisibleOnCurrentPlatform).toList();
|
||||
|
||||
const List<FeatureHighlight> featureMessageHighlights = [
|
||||
FeatureHighlight(
|
||||
image: 'assets/feature_message/share_quality.webp',
|
||||
titleKey: 'share_quality_title',
|
||||
bodyKey: 'share_quality_body',
|
||||
),
|
||||
FeatureHighlight(
|
||||
image: 'assets/feature_message/slideshow.webp',
|
||||
titleKey: 'slideshow_title',
|
||||
bodyKey: 'slideshow_body',
|
||||
),
|
||||
FeatureHighlight(
|
||||
image: 'assets/feature_message/recently_added.webp',
|
||||
titleKey: 'recently_added_title',
|
||||
bodyKey: 'recently_added_body',
|
||||
),
|
||||
|
||||
FeatureHighlight(image: 'assets/feature_message/ocr.webp', titleKey: 'ocr_title', bodyKey: 'ocr_body'),
|
||||
FeatureHighlight(
|
||||
image: 'assets/feature_message/open_in_immich.webp',
|
||||
titleKey: 'open_in_immich_title',
|
||||
bodyKey: 'open_in_immich_body',
|
||||
platform: [.android],
|
||||
),
|
||||
FeatureHighlight(titleKey: 'upload_to_album_title', bodyKey: 'upload_to_album_body'),
|
||||
];
|
||||
@@ -6,7 +6,6 @@ import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
|
||||
enum SettingsKey<T> {
|
||||
// Theme
|
||||
@@ -74,10 +73,7 @@ enum SettingsKey<T> {
|
||||
slideshowRepeat<bool>(),
|
||||
slideshowDuration<int>(),
|
||||
slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)),
|
||||
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values)),
|
||||
|
||||
// Feature message
|
||||
featureMessageSeenRelease<SemVer>(codec: _SemVerCodec());
|
||||
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values));
|
||||
|
||||
final _SettingsCodec<T>? _codecOverride;
|
||||
|
||||
@@ -143,16 +139,6 @@ final class _DateTimeCodec extends _SettingsCodec<DateTime> {
|
||||
DateTime decode(String raw) => DateTime.parse(raw);
|
||||
}
|
||||
|
||||
final class _SemVerCodec extends _SettingsCodec<SemVer> {
|
||||
const _SemVerCodec();
|
||||
|
||||
@override
|
||||
String encode(SemVer value) => value.toString();
|
||||
|
||||
@override
|
||||
SemVer decode(String raw) => SemVer.fromString(raw);
|
||||
}
|
||||
|
||||
final class _MapCodec<K extends Object, V extends Object> extends _SettingsCodec<Map<K, V>> {
|
||||
final _SettingsCodec<K> _keyCodec;
|
||||
final _SettingsCodec<V> _valueCodec;
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import 'package:immich_mobile/domain/models/feature_message.model.dart';
|
||||
import 'package:immich_mobile/domain/models/settings_key.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
|
||||
class FeatureMessageService {
|
||||
final SettingsRepository _settingsRepository;
|
||||
|
||||
const FeatureMessageService(this._settingsRepository);
|
||||
|
||||
bool shouldShow() {
|
||||
final seen = _settingsRepository.appConfig.featureMessage.seenRelease;
|
||||
return featureMessageHighlights.isNotEmpty && featureMessageRelease > seen;
|
||||
}
|
||||
|
||||
Future<void> markSeen() => _settingsRepository.write(SettingsKey.featureMessageSeenRelease, featureMessageRelease);
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/settings/advanced_settings.dart';
|
||||
import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_settings.dart';
|
||||
@@ -88,14 +87,6 @@ class _MobileLayout extends StatelessWidget {
|
||||
],
|
||||
)
|
||||
.toList();
|
||||
settings.add(
|
||||
SettingsCard(
|
||||
icon: Icons.auto_awesome_outlined,
|
||||
title: context.t.whats_new,
|
||||
subtitle: context.t.whats_new_settings_subtitle,
|
||||
settingRoute: const WhatsNewRoute(),
|
||||
),
|
||||
);
|
||||
return ListView(padding: const EdgeInsets.only(top: 10.0, bottom: 60), children: [...settings]);
|
||||
}
|
||||
}
|
||||
@@ -125,13 +116,6 @@ class _TabletLayout extends HookWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: ListTile(
|
||||
title: Text('whats_new'.tr()),
|
||||
leading: const Icon(Icons.auto_awesome_outlined),
|
||||
onTap: () => context.pushRoute(const WhatsNewRoute()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -3,42 +3,14 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/memory/memory_lane.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/feature_message/feature_message_dialog.widget.dart';
|
||||
import 'package:immich_mobile/providers/feature_message.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
||||
|
||||
@RoutePage()
|
||||
class MainTimelinePage extends ConsumerStatefulWidget {
|
||||
class MainTimelinePage extends ConsumerWidget {
|
||||
const MainTimelinePage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<MainTimelinePage> createState() => _MainTimelinePageState();
|
||||
}
|
||||
|
||||
class _MainTimelinePageState extends ConsumerState<MainTimelinePage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final service = ref.read(featureMessageServiceProvider);
|
||||
if (!service.shouldShow()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await service.markSeen();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await showFeatureMessageDialog(context);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final hasMemories = ref.watch(driftMemoryFutureProvider.select((state) => state.value?.isNotEmpty ?? false));
|
||||
return Timeline(
|
||||
topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()),
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/feature_message.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/feature_message/feature_message_placeholder.widget.dart';
|
||||
|
||||
@RoutePage()
|
||||
class WhatsNewPage extends StatelessWidget {
|
||||
const WhatsNewPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final highlights = visibleFeatureMessageHighlights;
|
||||
return Scaffold(
|
||||
appBar: AppBar(centerTitle: false, title: Text(context.t.whats_new)),
|
||||
body: ListView.separated(
|
||||
padding: const EdgeInsets.only(top: 16, bottom: 64),
|
||||
itemCount: highlights.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 24),
|
||||
itemBuilder: (_, index) => _HighlightCard(highlight: highlights[index]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HighlightCard extends StatelessWidget {
|
||||
final FeatureHighlight highlight;
|
||||
|
||||
const _HighlightCard({required this.highlight});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = context.colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(18)),
|
||||
border: Border.all(color: scheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(18)),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 256,
|
||||
child: highlight.image == null
|
||||
? const FeatureMessagePlaceholder()
|
||||
: Image.asset(
|
||||
highlight.image!,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, _, __) => const FeatureMessagePlaceholder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(highlight.titleKey.tr(), style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
highlight.bodyKey.tr(),
|
||||
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/feature_message.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/feature_message/feature_message_placeholder.widget.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
|
||||
Future<void> showFeatureMessageDialog(BuildContext context) {
|
||||
return showGeneralDialog<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
barrierDismissible: true,
|
||||
barrierLabel: context.t.whats_new,
|
||||
barrierColor: Colors.black.withValues(alpha: 0.55),
|
||||
transitionDuration: const Duration(milliseconds: 280),
|
||||
pageBuilder: (_, __, ___) => const _FeatureMessageDialog(),
|
||||
transitionBuilder: (_, animation, __, child) {
|
||||
final curved = CurvedAnimation(parent: animation, curve: Curves.easeOutCubic, reverseCurve: Curves.easeInCubic);
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: ScaleTransition(scale: Tween<double>(begin: 0.94, end: 1.0).animate(curved), child: child),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _FeatureMessageDialog extends StatefulWidget {
|
||||
const _FeatureMessageDialog();
|
||||
|
||||
@override
|
||||
State<_FeatureMessageDialog> createState() => _FeatureMessageDialogState();
|
||||
}
|
||||
|
||||
class _FeatureMessageDialogState extends State<_FeatureMessageDialog> with SingleTickerProviderStateMixin {
|
||||
static const double _radius = 24;
|
||||
|
||||
final PageController _controller = PageController();
|
||||
late final AnimationController _borderController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 7),
|
||||
)..repeat();
|
||||
final List<FeatureHighlight> _highlights = visibleFeatureMessageHighlights;
|
||||
int _index = 0;
|
||||
|
||||
bool get _isLast => _index >= _highlights.length - 1;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_borderController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _advance() {
|
||||
if (_isLast) {
|
||||
Navigator.of(context).pop();
|
||||
return;
|
||||
}
|
||||
_controller.nextPage(duration: const Duration(milliseconds: 320), curve: Curves.easeOutCubic);
|
||||
}
|
||||
|
||||
List<Color> _borderColors(BuildContext context) {
|
||||
final scheme = context.colorScheme;
|
||||
// Mute the hues toward the surface and drop opacity in dark mode to keep it gentle.
|
||||
Color tone(Color c) => context.isDarkTheme ? Color.lerp(c, scheme.surface, 0.45)!.withValues(alpha: 0.6) : c;
|
||||
return [tone(scheme.primary), tone(scheme.tertiary), tone(scheme.secondary), tone(scheme.primary)];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 64),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
backgroundColor: context.isDarkTheme ? context.colorScheme.surfaceContainerLow : Colors.white,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(_radius))),
|
||||
child: AnimatedBuilder(
|
||||
animation: _borderController,
|
||||
builder: (context, child) => CustomPaint(
|
||||
foregroundPainter: _GradientBorderPainter(
|
||||
rotation: _borderController.value,
|
||||
colors: _borderColors(context),
|
||||
radius: _radius,
|
||||
strokeWidth: 3,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: context.height * 0.9, maxWidth: 480),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 20, 24, 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
context.t.whats_new,
|
||||
style: context.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
context.t.whats_new_version(version: featureMessageRelease.toString()),
|
||||
style: context.textTheme.bodyLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
Expanded(
|
||||
child: PageView.builder(
|
||||
controller: _controller,
|
||||
itemCount: _highlights.length,
|
||||
onPageChanged: (i) => setState(() => _index = i),
|
||||
itemBuilder: (_, index) => _FeaturePage(highlight: _highlights[index]),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_PageDots(controller: _controller, index: _index, count: _highlights.length),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 26),
|
||||
child: Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
style: TextButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14)),
|
||||
child: Text(context.t.skip),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(100)),
|
||||
boxShadow: [
|
||||
// Soft wide primary glow.
|
||||
BoxShadow(
|
||||
color: context.primaryColor.withValues(alpha: 0.38),
|
||||
blurRadius: 22,
|
||||
spreadRadius: -4,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
// Tight contact shadow for grounding.
|
||||
BoxShadow(
|
||||
color: context.primaryColor.withValues(alpha: 0.22),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: FilledButton(
|
||||
onPressed: _advance,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
elevation: 0,
|
||||
textStyle: context.textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Text(_isLast ? context.t.ok : context.t.next, key: ValueKey(_isLast)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GradientBorderPainter extends CustomPainter {
|
||||
const _GradientBorderPainter({
|
||||
required this.rotation,
|
||||
required this.colors,
|
||||
required this.radius,
|
||||
this.strokeWidth = 3,
|
||||
});
|
||||
|
||||
final double rotation;
|
||||
final List<Color> colors;
|
||||
final double radius;
|
||||
final double strokeWidth;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final inset = strokeWidth / 2;
|
||||
final rect = (Offset.zero & size).deflate(inset);
|
||||
final rrect = RRect.fromRectAndRadius(rect, Radius.circular(radius - inset));
|
||||
|
||||
final shader = SweepGradient(
|
||||
transform: GradientRotation(rotation * 2 * math.pi),
|
||||
colors: colors,
|
||||
).createShader(rect);
|
||||
|
||||
final paint = Paint()
|
||||
..shader = shader
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = strokeWidth;
|
||||
canvas.drawRRect(rrect, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_GradientBorderPainter oldDelegate) =>
|
||||
oldDelegate.rotation != rotation || !listEquals(oldDelegate.colors, colors);
|
||||
}
|
||||
|
||||
class _FeaturePage extends StatelessWidget {
|
||||
final FeatureHighlight highlight;
|
||||
|
||||
const _FeaturePage({required this.highlight});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = context.colorScheme;
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 0),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(18)),
|
||||
border: Border.all(color: scheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(18)),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 256,
|
||||
child: highlight.image == null
|
||||
? const FeatureMessagePlaceholder()
|
||||
: Image.asset(
|
||||
highlight.image!,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, _, __) => const FeatureMessagePlaceholder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 18, 24, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
highlight.titleKey.tr(),
|
||||
style: context.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700, fontSize: 24),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
highlight.bodyKey.tr(),
|
||||
style: context.textTheme.bodyLarge?.copyWith(color: scheme.onSurfaceVariant, height: 1.4),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PageDots extends StatelessWidget {
|
||||
final PageController controller;
|
||||
final int index;
|
||||
final int count;
|
||||
|
||||
const _PageDots({required this.controller, required this.index, required this.count});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final primary = context.primaryColor;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (context, _) {
|
||||
final page = controller.hasClients ? (controller.page ?? index.toDouble()) : index.toDouble();
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(count, (i) {
|
||||
final activeness = (1 - (page - i).abs()).clamp(0.0, 1.0);
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||
height: 7,
|
||||
width: 7 + 16 * activeness,
|
||||
decoration: BoxDecoration(
|
||||
color: Color.lerp(context.colorScheme.surfaceContainerHighest, primary, activeness),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
-105
@@ -1,105 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_logo.dart';
|
||||
|
||||
class _SplatColors {
|
||||
static const primary = Color(0xFF4250AF);
|
||||
static const info = Color(0xFF3B82F6);
|
||||
static const success = Color(0xFF2FB457);
|
||||
static const warning = Color(0xFFF2A73B);
|
||||
static const danger = Color(0xFFE5484D);
|
||||
}
|
||||
|
||||
class FeatureMessagePlaceholder extends StatelessWidget {
|
||||
const FeatureMessagePlaceholder({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
final cardColor = dark ? const Color(0xFF232228) : const Color(0xFFEEEDF4);
|
||||
final tileColor = dark ? const Color(0xFF2B2A32) : const Color(0xFFFBFAFE);
|
||||
final inkColor = dark ? const Color(0xFFE7E7EC) : const Color(0xFF1A1A1E);
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
// Fill a plain rectangle — the parent's ClipRRect handles the corner rounding,
|
||||
// so the placeholder doesn't round its own corners inside that clip.
|
||||
decoration: BoxDecoration(color: cardColor),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// ---- confetti motif (168 × 120 region) ----
|
||||
SizedBox(
|
||||
width: 168,
|
||||
height: 120,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
// scattered confetti
|
||||
Positioned(left: 6, top: 24, child: _dot(12, _SplatColors.primary)),
|
||||
Positioned(left: 80, top: -2, child: _dot(9, _SplatColors.danger)),
|
||||
Positioned(left: 148, top: 84, child: _dot(11, _SplatColors.success)),
|
||||
Positioned(left: 140, top: 14, child: _bar(22, 8, 0.49, _SplatColors.danger)), // ~28°
|
||||
Positioned(left: 2, top: 90, child: _bar(20, 8, -0.31, _SplatColors.info)), // ~-18°
|
||||
// tilted spark tile
|
||||
Positioned(
|
||||
left: 46,
|
||||
top: 18,
|
||||
child: Transform.rotate(
|
||||
angle: -0.105, // ~-6°
|
||||
child: Container(
|
||||
width: 84,
|
||||
height: 84,
|
||||
decoration: BoxDecoration(
|
||||
color: tileColor,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(18)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF0F122D).withValues(alpha: 0.22),
|
||||
blurRadius: 22,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Positioned(left: 12, top: 12, child: _dot(12, _SplatColors.warning)),
|
||||
const ImmichLogo(size: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.t.new_feature,
|
||||
style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: inkColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _dot(double d, Color c) => Container(
|
||||
width: d,
|
||||
height: d,
|
||||
decoration: BoxDecoration(color: c, shape: BoxShape.circle),
|
||||
);
|
||||
|
||||
static Widget _bar(double w, double h, double angle, Color c) => Transform.rotate(
|
||||
angle: angle,
|
||||
child: Container(
|
||||
width: w,
|
||||
height: h,
|
||||
decoration: BoxDecoration(color: c, borderRadius: const BorderRadius.all(Radius.circular(99))),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/services/feature_message.service.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
|
||||
final featureMessageServiceProvider = Provider<FeatureMessageService>(
|
||||
(ref) => FeatureMessageService(ref.read(settingsProvider)),
|
||||
);
|
||||
@@ -38,7 +38,6 @@ import 'package:immich_mobile/pages/share_intent/share_intent.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/cleanup_preview.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/feature_message/whats_new.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/download_info.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_activities.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_album.page.dart';
|
||||
@@ -132,7 +131,6 @@ class AppRouter extends RootStackRouter {
|
||||
AutoRoute(page: ProfilePictureCropRoute.page),
|
||||
AutoRoute(page: SettingsRoute.page, guards: [_duplicateGuard]),
|
||||
AutoRoute(page: SettingsSubRoute.page, guards: [_duplicateGuard]),
|
||||
AutoRoute(page: WhatsNewRoute.page, guards: [_duplicateGuard]),
|
||||
AutoRoute(page: AppLogRoute.page, guards: [_duplicateGuard]),
|
||||
AutoRoute(page: AppLogDetailRoute.page, guards: [_duplicateGuard]),
|
||||
AutoRoute(page: FolderRoute.page, guards: [_authGuard]),
|
||||
|
||||
@@ -1872,19 +1872,3 @@ class TabShellRoute extends PageRouteInfo<void> {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [WhatsNewPage]
|
||||
class WhatsNewRoute extends PageRouteInfo<void> {
|
||||
const WhatsNewRoute({List<PageRouteInfo>? children})
|
||||
: super(WhatsNewRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'WhatsNewRoute';
|
||||
|
||||
static PageInfo page = PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const WhatsNewPage();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ final Map<String, Map<String, Object?>> openApiPatches = {
|
||||
'sharedLinks': SharedLinksResponse(enabled: true, sidebarWeb: false).toJson(),
|
||||
'cast': CastResponse(gCastEnabled: false).toJson(),
|
||||
'albums': {'defaultAssetOrder': 'desc'},
|
||||
'recentlyAdded': RecentlyAddedResponse(sidebarWeb: false).toJson(),
|
||||
},
|
||||
'ServerConfigDto': {
|
||||
'mapLightStyleUrl': 'https://tiles.immich.cloud/v1/style/light.json',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
|
||||
String? getVersionCompatibilityMessage({required SemVer serverVersion, required SemVer appVersion}) {
|
||||
String? getVersionCompatibilityMessage(SemVer serverVersion, SemVer appVersion) {
|
||||
// Add latest compat info up top
|
||||
|
||||
// ensure mobile app major version is not behind server major version
|
||||
|
||||
@@ -18,7 +18,6 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/feature_message.provider.dart';
|
||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/oauth.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
@@ -92,7 +91,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
final appSemVer = SemVer.fromString(packageInfo.version);
|
||||
final serverSemVer = serverInfo.serverVersion;
|
||||
warningMessage.value = getVersionCompatibilityMessage(serverVersion: serverSemVer, appVersion: appSemVer);
|
||||
warningMessage.value = getVersionCompatibilityMessage(appSemVer, serverSemVer);
|
||||
} catch (error) {
|
||||
warningMessage.value = 'Error checking version compatibility';
|
||||
}
|
||||
@@ -255,7 +254,6 @@ class LoginForm extends HookConsumerWidget {
|
||||
}
|
||||
unawaited(handleSyncFlow());
|
||||
ref.read(websocketProvider.notifier).connect();
|
||||
unawaited(ref.read(featureMessageServiceProvider).markSeen());
|
||||
unawaited(context.router.replaceAll([const TabShellRoute()]));
|
||||
return;
|
||||
}
|
||||
@@ -343,7 +341,6 @@ class LoginForm extends HookConsumerWidget {
|
||||
await getManageMediaPermission();
|
||||
}
|
||||
unawaited(handleSyncFlow());
|
||||
unawaited(ref.read(featureMessageServiceProvider).markSeen());
|
||||
unawaited(context.router.replaceAll([const TabShellRoute()]));
|
||||
return;
|
||||
}
|
||||
@@ -380,21 +377,11 @@ class LoginForm extends HookConsumerWidget {
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: context.isDarkTheme ? Colors.amber.shade700 : Colors.amber.shade100,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
border: Border.all(color: context.isDarkTheme ? Colors.amber.shade800 : Colors.amber[200]!, width: 2),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: Colors.amber.shade800),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Padding(padding: const EdgeInsets.only(top: 2), child: Text(warningMessage.value!)),
|
||||
),
|
||||
],
|
||||
color: context.isDarkTheme ? Colors.red.shade700 : Colors.red.shade100,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
border: Border.all(color: context.isDarkTheme ? Colors.red.shade900 : Colors.red[200]!),
|
||||
),
|
||||
child: Text(warningMessage.value!, textAlign: TextAlign.center),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Generated
-2
@@ -545,8 +545,6 @@ Class | Method | HTTP request | Description
|
||||
- [RatingsUpdate](doc//RatingsUpdate.md)
|
||||
- [ReactionLevel](doc//ReactionLevel.md)
|
||||
- [ReactionType](doc//ReactionType.md)
|
||||
- [RecentlyAddedResponse](doc//RecentlyAddedResponse.md)
|
||||
- [RecentlyAddedUpdate](doc//RecentlyAddedUpdate.md)
|
||||
- [ReleaseChannel](doc//ReleaseChannel.md)
|
||||
- [ReleaseEventV1](doc//ReleaseEventV1.md)
|
||||
- [ReleaseType](doc//ReleaseType.md)
|
||||
|
||||
Generated
-2
@@ -266,8 +266,6 @@ part 'model/ratings_response.dart';
|
||||
part 'model/ratings_update.dart';
|
||||
part 'model/reaction_level.dart';
|
||||
part 'model/reaction_type.dart';
|
||||
part 'model/recently_added_response.dart';
|
||||
part 'model/recently_added_update.dart';
|
||||
part 'model/release_channel.dart';
|
||||
part 'model/release_event_v1.dart';
|
||||
part 'model/release_type.dart';
|
||||
|
||||
Generated
-4
@@ -577,10 +577,6 @@ class ApiClient {
|
||||
return ReactionLevelTypeTransformer().decode(value);
|
||||
case 'ReactionType':
|
||||
return ReactionTypeTypeTransformer().decode(value);
|
||||
case 'RecentlyAddedResponse':
|
||||
return RecentlyAddedResponse.fromJson(value);
|
||||
case 'RecentlyAddedUpdate':
|
||||
return RecentlyAddedUpdate.fromJson(value);
|
||||
case 'ReleaseChannel':
|
||||
return ReleaseChannelTypeTransformer().decode(value);
|
||||
case 'ReleaseEventV1':
|
||||
|
||||
-100
@@ -1,100 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class RecentlyAddedResponse {
|
||||
/// Returns a new [RecentlyAddedResponse] instance.
|
||||
RecentlyAddedResponse({
|
||||
required this.sidebarWeb,
|
||||
});
|
||||
|
||||
/// Whether the recently added page appears in the web sidebar
|
||||
bool sidebarWeb;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is RecentlyAddedResponse &&
|
||||
other.sidebarWeb == sidebarWeb;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(sidebarWeb.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'RecentlyAddedResponse[sidebarWeb=$sidebarWeb]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'sidebarWeb'] = this.sidebarWeb;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [RecentlyAddedResponse] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static RecentlyAddedResponse? fromJson(dynamic value) {
|
||||
upgradeDto(value, "RecentlyAddedResponse");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return RecentlyAddedResponse(
|
||||
sidebarWeb: mapValueOfType<bool>(json, r'sidebarWeb')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<RecentlyAddedResponse> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <RecentlyAddedResponse>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = RecentlyAddedResponse.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, RecentlyAddedResponse> mapFromJson(dynamic json) {
|
||||
final map = <String, RecentlyAddedResponse>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = RecentlyAddedResponse.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of RecentlyAddedResponse-objects as value to a dart map
|
||||
static Map<String, List<RecentlyAddedResponse>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<RecentlyAddedResponse>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = RecentlyAddedResponse.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'sidebarWeb',
|
||||
};
|
||||
}
|
||||
|
||||
-108
@@ -1,108 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class RecentlyAddedUpdate {
|
||||
/// Returns a new [RecentlyAddedUpdate] instance.
|
||||
RecentlyAddedUpdate({
|
||||
this.sidebarWeb = const Optional.absent(),
|
||||
});
|
||||
|
||||
/// Whether the recently added page appears in the web sidebar
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
Optional<bool?> sidebarWeb;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is RecentlyAddedUpdate &&
|
||||
other.sidebarWeb == sidebarWeb;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(sidebarWeb == null ? 0 : sidebarWeb!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'RecentlyAddedUpdate[sidebarWeb=$sidebarWeb]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
if (this.sidebarWeb.isPresent) {
|
||||
final value = this.sidebarWeb.value;
|
||||
json[r'sidebarWeb'] = value;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [RecentlyAddedUpdate] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static RecentlyAddedUpdate? fromJson(dynamic value) {
|
||||
upgradeDto(value, "RecentlyAddedUpdate");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return RecentlyAddedUpdate(
|
||||
sidebarWeb: json.containsKey(r'sidebarWeb') ? Optional.present(mapValueOfType<bool>(json, r'sidebarWeb')) : const Optional.absent(),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<RecentlyAddedUpdate> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <RecentlyAddedUpdate>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = RecentlyAddedUpdate.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, RecentlyAddedUpdate> mapFromJson(dynamic json) {
|
||||
final map = <String, RecentlyAddedUpdate>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = RecentlyAddedUpdate.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of RecentlyAddedUpdate-objects as value to a dart map
|
||||
static Map<String, List<RecentlyAddedUpdate>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<RecentlyAddedUpdate>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = RecentlyAddedUpdate.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
};
|
||||
}
|
||||
|
||||
+1
-9
@@ -22,7 +22,6 @@ class UserPreferencesResponseDto {
|
||||
required this.people,
|
||||
required this.purchase,
|
||||
required this.ratings,
|
||||
required this.recentlyAdded,
|
||||
required this.sharedLinks,
|
||||
required this.tags,
|
||||
});
|
||||
@@ -45,8 +44,6 @@ class UserPreferencesResponseDto {
|
||||
|
||||
RatingsResponse ratings;
|
||||
|
||||
RecentlyAddedResponse recentlyAdded;
|
||||
|
||||
SharedLinksResponse sharedLinks;
|
||||
|
||||
TagsResponse tags;
|
||||
@@ -62,7 +59,6 @@ class UserPreferencesResponseDto {
|
||||
other.people == people &&
|
||||
other.purchase == purchase &&
|
||||
other.ratings == ratings &&
|
||||
other.recentlyAdded == recentlyAdded &&
|
||||
other.sharedLinks == sharedLinks &&
|
||||
other.tags == tags;
|
||||
|
||||
@@ -78,12 +74,11 @@ class UserPreferencesResponseDto {
|
||||
(people.hashCode) +
|
||||
(purchase.hashCode) +
|
||||
(ratings.hashCode) +
|
||||
(recentlyAdded.hashCode) +
|
||||
(sharedLinks.hashCode) +
|
||||
(tags.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'UserPreferencesResponseDto[albums=$albums, cast=$cast, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, recentlyAdded=$recentlyAdded, sharedLinks=$sharedLinks, tags=$tags]';
|
||||
String toString() => 'UserPreferencesResponseDto[albums=$albums, cast=$cast, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -96,7 +91,6 @@ class UserPreferencesResponseDto {
|
||||
json[r'people'] = this.people;
|
||||
json[r'purchase'] = this.purchase;
|
||||
json[r'ratings'] = this.ratings;
|
||||
json[r'recentlyAdded'] = this.recentlyAdded;
|
||||
json[r'sharedLinks'] = this.sharedLinks;
|
||||
json[r'tags'] = this.tags;
|
||||
return json;
|
||||
@@ -120,7 +114,6 @@ class UserPreferencesResponseDto {
|
||||
people: PeopleResponse.fromJson(json[r'people'])!,
|
||||
purchase: PurchaseResponse.fromJson(json[r'purchase'])!,
|
||||
ratings: RatingsResponse.fromJson(json[r'ratings'])!,
|
||||
recentlyAdded: RecentlyAddedResponse.fromJson(json[r'recentlyAdded'])!,
|
||||
sharedLinks: SharedLinksResponse.fromJson(json[r'sharedLinks'])!,
|
||||
tags: TagsResponse.fromJson(json[r'tags'])!,
|
||||
);
|
||||
@@ -179,7 +172,6 @@ class UserPreferencesResponseDto {
|
||||
'people',
|
||||
'purchase',
|
||||
'ratings',
|
||||
'recentlyAdded',
|
||||
'sharedLinks',
|
||||
'tags',
|
||||
};
|
||||
|
||||
+1
-17
@@ -23,7 +23,6 @@ class UserPreferencesUpdateDto {
|
||||
this.people = const Optional.absent(),
|
||||
this.purchase = const Optional.absent(),
|
||||
this.ratings = const Optional.absent(),
|
||||
this.recentlyAdded = const Optional.absent(),
|
||||
this.sharedLinks = const Optional.absent(),
|
||||
this.tags = const Optional.absent(),
|
||||
});
|
||||
@@ -108,14 +107,6 @@ class UserPreferencesUpdateDto {
|
||||
///
|
||||
Optional<RatingsUpdate?> ratings;
|
||||
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
Optional<RecentlyAddedUpdate?> recentlyAdded;
|
||||
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
@@ -144,7 +135,6 @@ class UserPreferencesUpdateDto {
|
||||
other.people == people &&
|
||||
other.purchase == purchase &&
|
||||
other.ratings == ratings &&
|
||||
other.recentlyAdded == recentlyAdded &&
|
||||
other.sharedLinks == sharedLinks &&
|
||||
other.tags == tags;
|
||||
|
||||
@@ -161,12 +151,11 @@ class UserPreferencesUpdateDto {
|
||||
(people == null ? 0 : people!.hashCode) +
|
||||
(purchase == null ? 0 : purchase!.hashCode) +
|
||||
(ratings == null ? 0 : ratings!.hashCode) +
|
||||
(recentlyAdded == null ? 0 : recentlyAdded!.hashCode) +
|
||||
(sharedLinks == null ? 0 : sharedLinks!.hashCode) +
|
||||
(tags == null ? 0 : tags!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'UserPreferencesUpdateDto[albums=$albums, avatar=$avatar, cast=$cast, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, recentlyAdded=$recentlyAdded, sharedLinks=$sharedLinks, tags=$tags]';
|
||||
String toString() => 'UserPreferencesUpdateDto[albums=$albums, avatar=$avatar, cast=$cast, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -210,10 +199,6 @@ class UserPreferencesUpdateDto {
|
||||
final value = this.ratings.value;
|
||||
json[r'ratings'] = value;
|
||||
}
|
||||
if (this.recentlyAdded.isPresent) {
|
||||
final value = this.recentlyAdded.value;
|
||||
json[r'recentlyAdded'] = value;
|
||||
}
|
||||
if (this.sharedLinks.isPresent) {
|
||||
final value = this.sharedLinks.value;
|
||||
json[r'sharedLinks'] = value;
|
||||
@@ -244,7 +229,6 @@ class UserPreferencesUpdateDto {
|
||||
people: json.containsKey(r'people') ? Optional.present(PeopleUpdate.fromJson(json[r'people'])) : const Optional.absent(),
|
||||
purchase: json.containsKey(r'purchase') ? Optional.present(PurchaseUpdate.fromJson(json[r'purchase'])) : const Optional.absent(),
|
||||
ratings: json.containsKey(r'ratings') ? Optional.present(RatingsUpdate.fromJson(json[r'ratings'])) : const Optional.absent(),
|
||||
recentlyAdded: json.containsKey(r'recentlyAdded') ? Optional.present(RecentlyAddedUpdate.fromJson(json[r'recentlyAdded'])) : const Optional.absent(),
|
||||
sharedLinks: json.containsKey(r'sharedLinks') ? Optional.present(SharedLinksUpdate.fromJson(json[r'sharedLinks'])) : const Optional.absent(),
|
||||
tags: json.containsKey(r'tags') ? Optional.present(TagsUpdate.fromJson(json[r'tags'])) : const Optional.absent(),
|
||||
);
|
||||
|
||||
@@ -912,6 +912,13 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
immich_native_core:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../native/immich_native_core"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.1.0"
|
||||
immich_ui:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1124,6 +1131,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.19.1"
|
||||
native_toolchain_rust:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_rust
|
||||
sha256: faa57d2258a3b0fd2a634054f54e4496c9fcbd971977e7d2b7e6916d56892857
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4+0"
|
||||
native_video_player:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1755,6 +1770,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.4"
|
||||
toml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: toml
|
||||
sha256: "35a35f782228656a2af31e8c73d1353cc4ef3d683fd68af1111b44631879c05e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.18.0"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+2
-1
@@ -39,6 +39,8 @@ dependencies:
|
||||
hooks_riverpod: ^2.6.1
|
||||
http: ^1.6.0
|
||||
image_picker: ^1.2.1
|
||||
immich_native_core:
|
||||
path: ../native/immich_native_core
|
||||
immich_ui:
|
||||
path: './packages/ui'
|
||||
intl: ^0.20.2
|
||||
@@ -119,7 +121,6 @@ flutter:
|
||||
uses-material-design: true
|
||||
assets:
|
||||
- assets/
|
||||
- assets/feature_message/
|
||||
fonts:
|
||||
- family: GoogleSans
|
||||
fonts:
|
||||
|
||||
@@ -9,16 +9,16 @@ void main() {
|
||||
|
||||
test('returns message when app major is behind server major', () {
|
||||
final result = getVersionCompatibilityMessage(
|
||||
serverVersion: const SemVer(major: 2, minor: 0, patch: 0),
|
||||
appVersion: const SemVer(major: 1, minor: 200, patch: 0),
|
||||
const SemVer(major: 2, minor: 0, patch: 0),
|
||||
const SemVer(major: 1, minor: 200, patch: 0),
|
||||
);
|
||||
expect(result, message);
|
||||
});
|
||||
|
||||
test('returns null when app major matches server major', () {
|
||||
final result = getVersionCompatibilityMessage(
|
||||
serverVersion: const SemVer(major: 2, minor: 0, patch: 0),
|
||||
appVersion: const SemVer(major: 2, minor: 0, patch: 0),
|
||||
const SemVer(major: 2, minor: 0, patch: 0),
|
||||
const SemVer(major: 2, minor: 0, patch: 0),
|
||||
);
|
||||
expect(result, null);
|
||||
});
|
||||
@@ -30,16 +30,16 @@ void main() {
|
||||
|
||||
test('returns message when app major is more than one ahead of server', () {
|
||||
final result = getVersionCompatibilityMessage(
|
||||
serverVersion: const SemVer(major: 1, minor: 200, patch: 0),
|
||||
appVersion: const SemVer(major: 3, minor: 0, patch: 0),
|
||||
const SemVer(major: 1, minor: 200, patch: 0),
|
||||
const SemVer(major: 3, minor: 0, patch: 0),
|
||||
);
|
||||
expect(result, message);
|
||||
});
|
||||
|
||||
test('returns null when app major is exactly one ahead of server', () {
|
||||
final result = getVersionCompatibilityMessage(
|
||||
serverVersion: const SemVer(major: 1, minor: 200, patch: 0),
|
||||
appVersion: const SemVer(major: 2, minor: 0, patch: 0),
|
||||
const SemVer(major: 1, minor: 200, patch: 0),
|
||||
const SemVer(major: 2, minor: 0, patch: 0),
|
||||
);
|
||||
expect(result, null);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/target
|
||||
smoke/*.node
|
||||
# generated + committed (regen via `mise run codegen`):
|
||||
# crates/immich_core_dart/include/immich_core.h (cbindgen)
|
||||
# immich_native_core/lib/immich_native_core_bindings_generated.dart (ffigen)
|
||||
Generated
+619
@@ -0,0 +1,619 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cbindgen"
|
||||
version = "0.29.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"indexmap",
|
||||
"log",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"syn",
|
||||
"tempfile",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctor"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01334b89b69ff726750c5ce5073fc8bd860e99aa9a8fc5ca11b04730e3aee97a"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "immich_core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"memmap2",
|
||||
"sha1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "immich_core_dart"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"cbindgen",
|
||||
"immich_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "immich_core_napi"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"immich_core",
|
||||
"napi",
|
||||
"napi-build",
|
||||
"napi-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||
|
||||
[[package]]
|
||||
name = "memmap2"
|
||||
version = "0.9.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi"
|
||||
version = "3.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b41bda2ac390efb5e8d22025d925ccc3f3807d8c1bea6d19b36127247c4b8f83"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"ctor",
|
||||
"futures",
|
||||
"napi-build",
|
||||
"napi-sys",
|
||||
"nohash-hasher",
|
||||
"rustc-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-build"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1"
|
||||
|
||||
[[package]]
|
||||
name = "napi-derive"
|
||||
version = "3.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61d66f70256ad5aef58659966064471d0ad90e2897bc36a5a5e0389c85aabc1e"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"ctor",
|
||||
"napi-derive-backend",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-derive-backend"
|
||||
version = "5.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81b4b08f15eed7a2a20c3f4c6314013fc3ac890a3afa9892b594485299ebdb2d"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"semver",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-sys"
|
||||
version = "3.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f5bcdf71abd3a50d00b49c1c2c75251cb3c913777d6139cd37dabc093a5e400"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nohash-hasher"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.9.12+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow 0.7.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.7.5+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
||||
dependencies = [
|
||||
"winnow 1.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
@@ -0,0 +1,44 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/immich_core",
|
||||
"crates/immich_core_dart",
|
||||
"crates/immich_core_napi",
|
||||
]
|
||||
|
||||
# shared logic lives in immich_core (no binding deps). each binding crate is a
|
||||
# thin wrapper that picks its own crate-type: immich_core_dart -> cdylib/staticlib
|
||||
# for dart:ffi (mobile), immich_core_napi -> cdylib (.node) for the node server.
|
||||
# capabilities (hashing, exif, ...) are cargo features on immich_core so both
|
||||
# bindings opt into the same set. crate-type can't be feature-gated, which is why
|
||||
# the bindings are separate crates rather than one crate with feature flags.
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
# single source of truth for all external dep versions. inner crates reference
|
||||
# these with `{ workspace = true }` and never hardcode a version.
|
||||
# default-features = false MUST live here (workspace level) — cargo ignores it if
|
||||
# set only on the inner crate. inner crates then add the minimal features they need.
|
||||
[workspace.dependencies]
|
||||
sha1 = { version = "0.11", default-features = false }
|
||||
memmap2 = { version = "0.9", default-features = false }
|
||||
napi = { version = "3", default-features = false }
|
||||
napi-derive = "3"
|
||||
napi-build = "2"
|
||||
cbindgen = { version = "0.29", default-features = false }
|
||||
|
||||
# CI-enforced (not review-hoped): the boundary crate also #![deny]s unwrap/expect.
|
||||
[workspace.lints.clippy]
|
||||
undocumented_unsafe_blocks = "deny"
|
||||
|
||||
# NB: no `panic = "abort"` — the FFI boundary relies on catch_unwind, which is a
|
||||
# no-op under abort. default unwind is what lets a boundary panic become a null
|
||||
# return instead of taking down the host (Flutter app / node server).
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
@@ -0,0 +1,61 @@
|
||||
# immich_native_core (PoC)
|
||||
|
||||
Shared Rust core consumed by the **mobile** app (Flutter, dart:ffi) and the
|
||||
**server** (Node, napi `.node` addon).
|
||||
|
||||
Status: **plumbing PoC.** It proves the wiring — Rust → codegen → build-from-source
|
||||
on each app build → load on both platforms — not a perf win yet. The one capability
|
||||
(`sha1_hex`) is single-shot in-memory, and the local-sync probe found hashing isn't
|
||||
the hot path; a measured payload is the next step. `core_version` is a smoke
|
||||
entrypoint. Mobile is the consumed path; the server napi crate builds and
|
||||
round-trips but is not wired into the server yet.
|
||||
|
||||
## Layout
|
||||
```
|
||||
crates/
|
||||
immich_core pure logic, no binding deps. capabilities = cargo features (hashing).
|
||||
immich_core_dart cdylib/staticlib + cbindgen header for dart:ffi (mobile)
|
||||
immich_core_napi cdylib (.node) via napi-rs (server, unwired)
|
||||
immich_native_core/ the Flutter package mobile depends on. build hook + ffigen @Native bindings.
|
||||
smoke/ host dart + node roundtrip scripts (no device)
|
||||
```
|
||||
Bindings are separate crates (Cargo can't gate `crate-type` by feature).
|
||||
|
||||
## How the native lib is built (Flutter native assets — no prebuilt, no CI)
|
||||
`immich_native_core/hook/build.dart` (`native_toolchain_rust`) compiles
|
||||
`crates/immich_core_dart` **from source on every app build** via rustup and bundles
|
||||
it as a Flutter *code asset*. The Dart side uses ffigen `@Native` externals bound to
|
||||
that asset — no `DynamicLibrary`, no prebuilt artifacts, no fetch/publish/separate-repo.
|
||||
|
||||
Native assets is on by default on Flutter stable (3.38+), so a stock `flutter build`
|
||||
runs the hook. Each builder needs **rustup** (the hook auto-installs the pinned
|
||||
toolchain + targets from `crates/immich_core_dart/rust-toolchain.toml`).
|
||||
|
||||
## Dev commands (mise)
|
||||
```
|
||||
mise run build cargo build --workspace
|
||||
mise run test cargo test --workspace (host Rust tests, incl. FFI-boundary)
|
||||
mise run lint clippy -D warnings (fmt: mise run fmt)
|
||||
mise run codegen regen cbindgen header + ffigen @Native bindings — commit the result
|
||||
mise run test:flutter HOST FFI roundtrip through the real build hook (no device)
|
||||
mise run smoke Rust tests + host dart:ffi + host napi roundtrips
|
||||
```
|
||||
|
||||
## Add a capability (end to end)
|
||||
1. add the logic to `crates/immich_core` (behind a cargo feature if it pulls a dep).
|
||||
2. expose a C entry in `crates/immich_core_dart/src/lib.rs` — `#[no_mangle] pub extern "C"`,
|
||||
wrap the body in `guard(...)` (panic at the boundary → null, never unwind into the host),
|
||||
validate pointers, return Rust-owned memory the caller frees via `immich_core_free_string`.
|
||||
3. `mise run codegen` — regenerates the committed cbindgen header + ffigen `@Native` bindings.
|
||||
4. add an ergonomic wrapper + null-check in `immich_native_core/lib/immich_native_core.dart`.
|
||||
5. (optional) mirror it in `crates/immich_core_napi/src/lib.rs` for the server.
|
||||
6. `mise run test:flutter` (host) + add a case to `immich_native_core/test/`, and to
|
||||
`mobile/integration_test/native_core_test.dart` to exercise it on a device.
|
||||
|
||||
## Consume from immich/mobile
|
||||
`immich_native_core: { path: ../native/immich_native_core }` in `mobile/pubspec.yaml`,
|
||||
then `dart pub get`. No app-level Gradle/Podfile edits — the hook builds + bundles the
|
||||
lib. Builders need rustup. See the package README for the iOS App-Extension caveat.
|
||||
|
||||
`/native/` is codeowned by @santoshakil + @mertalev. License: reuses the immich
|
||||
repo-root AGPL-3.0 (no separate license file).
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "immich_core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["hashing", "image"]
|
||||
hashing = ["dep:sha1", "dep:memmap2"]
|
||||
image = [] # pure pixel math, no deps
|
||||
|
||||
[dependencies]
|
||||
sha1 = { workspace = true, optional = true }
|
||||
memmap2 = { workspace = true, optional = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,75 @@
|
||||
//! SHA-1 hashing. SHA-1 is immich's asset-identity checksum (server contract):
|
||||
//! the algorithm is fixed. The win is in HOW it's computed — `sha1_file` mmaps the
|
||||
//! file and feeds the OS-paged bytes straight to a hardware-accelerated digest, so
|
||||
//! the whole file never lands in the caller's heap and there's no read+copy hop.
|
||||
|
||||
use sha1::{Digest, Sha1};
|
||||
use std::fmt::Write;
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
/// Lowercase-hex SHA-1 of a byte slice.
|
||||
pub fn sha1_hex(bytes: &[u8]) -> String {
|
||||
let digest = Sha1::digest(bytes);
|
||||
let mut out = String::with_capacity(40);
|
||||
for b in digest {
|
||||
let _ = write!(out, "{b:02x}");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Lowercase-hex SHA-1 of the file at `path`, read via mmap. The OS pages the file
|
||||
/// in on demand, so memory stays bounded regardless of file size — no full read
|
||||
/// into a buffer, no copy.
|
||||
pub fn sha1_file(path: impl AsRef<Path>) -> io::Result<String> {
|
||||
let file = File::open(path)?;
|
||||
if file.metadata()?.len() == 0 {
|
||||
return Ok(sha1_hex(&[]));
|
||||
}
|
||||
// SAFETY: the file is opened read-only and the mapping is read as immutable
|
||||
// bytes for the duration of the hash. immich assets are not mutated in place;
|
||||
// a concurrent truncation could SIGBUS, which is the documented mmap trade-off.
|
||||
let mmap = unsafe { memmap2::Mmap::map(&file)? };
|
||||
Ok(sha1_hex(&mmap))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sha1_known_vector() {
|
||||
// FIPS-180 worked example.
|
||||
assert_eq!(sha1_hex(b"abc"), "a9993e364706816aba3e25717850c26c9cd0d89d");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha1_empty() {
|
||||
assert_eq!(sha1_hex(b""), "da39a3ee5e6b4b0d3255bfef95601890afd80709");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha1_file_matches_in_memory() {
|
||||
let dir = std::env::temp_dir();
|
||||
let path = dir.join(format!("immich_core_sha1_file_{}.bin", std::process::id()));
|
||||
let data: Vec<u8> = (0..100_000u32).map(|i| (i % 251) as u8).collect();
|
||||
std::fs::write(&path, &data).unwrap();
|
||||
assert_eq!(sha1_file(&path).unwrap(), sha1_hex(&data));
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha1_file_empty() {
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("immich_core_empty_{}.bin", std::process::id()));
|
||||
std::fs::write(&path, b"").unwrap();
|
||||
assert_eq!(sha1_file(&path).unwrap(), sha1_hex(b""));
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha1_file_missing_errors() {
|
||||
assert!(sha1_file("/no/such/immich_core/file").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! EXIF-orientation rotation of RGBA8888 pixel buffers, ported from the Android
|
||||
//! native_image.c (immich PR #29337). Lives here so the perf-critical pixel math
|
||||
//! exists once, tested, callable from any platform's decode pipeline (Android RAW
|
||||
//! today; the algorithm is platform-agnostic). The platform side keeps the bitmap
|
||||
//! lock + output allocation and calls this to fill the destination buffer.
|
||||
|
||||
// EXIF orientation values (androidx ExifInterface.ORIENTATION_*).
|
||||
const FLIP_HORIZONTAL: i32 = 2;
|
||||
const ROTATE_180: i32 = 3;
|
||||
const FLIP_VERTICAL: i32 = 4;
|
||||
const TRANSPOSE: i32 = 5;
|
||||
const ROTATE_90: i32 = 6;
|
||||
const TRANSVERSE: i32 = 7;
|
||||
const ROTATE_270: i32 = 8;
|
||||
|
||||
// 32x32 u32 tile = 4KB, L1-resident so a 90/270 transpose's scattered writes stay hot.
|
||||
const TILE: usize = 32;
|
||||
|
||||
/// Whether the orientation swaps width and height (the 90/270 + transpose family).
|
||||
pub fn swaps_dims(orientation: i32) -> bool {
|
||||
matches!(orientation, ROTATE_90 | ROTATE_270 | TRANSPOSE | TRANSVERSE)
|
||||
}
|
||||
|
||||
// (base, step_x, step_y): src pixel (sx,sy) maps to dst pixel index
|
||||
// base + sx*step_x + sy*step_y for a destination of width `dw`. Mirrors
|
||||
// native_image.c affine_for byte-for-byte. i64 so the math stays correct on 32-bit.
|
||||
fn affine_for(o: i32, sw: i64, sh: i64, dw: i64) -> (i64, i64, i64) {
|
||||
match o {
|
||||
ROTATE_90 => (sh - 1, dw, -1),
|
||||
ROTATE_270 => ((sw - 1) * dw, -dw, 1),
|
||||
ROTATE_180 => ((sh - 1) * dw + (sw - 1), -1, -dw),
|
||||
FLIP_HORIZONTAL => (sw - 1, -1, dw),
|
||||
FLIP_VERTICAL => ((sh - 1) * dw, 1, -dw),
|
||||
TRANSPOSE => (0, dw, 1),
|
||||
TRANSVERSE => ((sw - 1) * dw + (sh - 1), -dw, -1),
|
||||
_ => (0, 1, dw),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotate `src` (RGBA8888, `sh` rows of `src_stride` bytes, `sw` pixels per row) into
|
||||
/// `dst` (densely packed, `dw*dh*4` bytes) for the given EXIF orientation, where
|
||||
/// (dw,dh) swap for the 90/270/transpose family. Returns `false` without touching
|
||||
/// out-of-range memory if the sizes are inconsistent, so the caller can fall back.
|
||||
/// Indexing is bounds-checked: a bad input fails safe (panic caught at the FFI
|
||||
/// boundary / false here), never an out-of-bounds write like the raw C.
|
||||
pub fn rotate_rgba8888(
|
||||
src: &[u8],
|
||||
src_stride: usize,
|
||||
sw: usize,
|
||||
sh: usize,
|
||||
orientation: i32,
|
||||
dst: &mut [u8],
|
||||
) -> bool {
|
||||
if sw == 0 || sh == 0 || src_stride < sw * 4 {
|
||||
return false;
|
||||
}
|
||||
let dw = if swaps_dims(orientation) { sh } else { sw };
|
||||
let dh = if swaps_dims(orientation) { sw } else { sh };
|
||||
if src.len() < src_stride * sh || dst.len() < dw * dh * 4 {
|
||||
return false;
|
||||
}
|
||||
let (base, step_x, step_y) = affine_for(orientation, sw as i64, sh as i64, dw as i64);
|
||||
for ty in (0..sh).step_by(TILE) {
|
||||
let y_end = (ty + TILE).min(sh);
|
||||
for tx in (0..sw).step_by(TILE) {
|
||||
let x_end = (tx + TILE).min(sw);
|
||||
for sy in ty..y_end {
|
||||
let row = sy * src_stride;
|
||||
let mut idx = base + sy as i64 * step_y + tx as i64 * step_x;
|
||||
for sx in tx..x_end {
|
||||
let s = row + sx * 4;
|
||||
let d = idx as usize * 4;
|
||||
dst[d..d + 4].copy_from_slice(&src[s..s + 4]);
|
||||
idx += step_x;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Independent textbook EXIF transform: src(sx,sy) -> dst(dx,dy). Verifies the
|
||||
// affine port against orientation *semantics*, not against itself.
|
||||
fn ref_dst_xy(o: i32, sx: usize, sy: usize, sw: usize, sh: usize) -> (usize, usize) {
|
||||
match o {
|
||||
FLIP_HORIZONTAL => (sw - 1 - sx, sy),
|
||||
ROTATE_180 => (sw - 1 - sx, sh - 1 - sy),
|
||||
FLIP_VERTICAL => (sx, sh - 1 - sy),
|
||||
TRANSPOSE => (sy, sx),
|
||||
ROTATE_90 => (sh - 1 - sy, sx),
|
||||
TRANSVERSE => (sh - 1 - sy, sw - 1 - sx),
|
||||
ROTATE_270 => (sy, sw - 1 - sx),
|
||||
_ => (sx, sy),
|
||||
}
|
||||
}
|
||||
|
||||
fn pixel(i: usize) -> [u8; 4] {
|
||||
[
|
||||
(i & 0xff) as u8,
|
||||
((i >> 8) & 0xff) as u8,
|
||||
((i >> 16) & 0xff) as u8,
|
||||
0xff,
|
||||
]
|
||||
}
|
||||
|
||||
fn check(o: i32, sw: usize, sh: usize) {
|
||||
let mut src = vec![0u8; sw * sh * 4];
|
||||
for sy in 0..sh {
|
||||
for sx in 0..sw {
|
||||
let i = sy * sw + sx;
|
||||
src[i * 4..i * 4 + 4].copy_from_slice(&pixel(i));
|
||||
}
|
||||
}
|
||||
let (dw, dh) = if swaps_dims(o) { (sh, sw) } else { (sw, sh) };
|
||||
let mut dst = vec![0u8; dw * dh * 4];
|
||||
assert!(rotate_rgba8888(&src, sw * 4, sw, sh, o, &mut dst));
|
||||
for sy in 0..sh {
|
||||
for sx in 0..sw {
|
||||
let (dx, dy) = ref_dst_xy(o, sx, sy, sw, sh);
|
||||
let di = dy * dw + dx;
|
||||
let si = sy * sw + sx;
|
||||
assert_eq!(&dst[di * 4..di * 4 + 4], &pixel(si), "o={o} src({sx},{sy})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_orientations_match_exif_reference() {
|
||||
for o in [1, 2, 3, 4, 5, 6, 7, 8] {
|
||||
check(o, 4, 3);
|
||||
check(o, 1, 5);
|
||||
check(o, 5, 1);
|
||||
check(o, 40, 33); // spans multiple tiles
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_for_normal_orientation() {
|
||||
let src: Vec<u8> = (0..24u8).collect(); // 2x3 RGBA
|
||||
let mut dst = vec![0u8; 24];
|
||||
assert!(rotate_rgba8888(&src, 8, 2, 3, 1, &mut dst));
|
||||
assert_eq!(src, dst);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn respects_src_stride_padding() {
|
||||
let (sw, sh, stride) = (2usize, 2usize, 12usize); // 4 bytes row padding
|
||||
let mut src = vec![0u8; stride * sh];
|
||||
for sy in 0..sh {
|
||||
for sx in 0..sw {
|
||||
let i = sy * sw + sx;
|
||||
src[sy * stride + sx * 4..sy * stride + sx * 4 + 4].copy_from_slice(&pixel(i));
|
||||
}
|
||||
}
|
||||
let mut dst = vec![0u8; sw * sh * 4];
|
||||
assert!(rotate_rgba8888(&src, stride, sw, sh, ROTATE_180, &mut dst));
|
||||
for i in 0..4 {
|
||||
assert_eq!(&dst[i * 4..i * 4 + 4], &pixel(3 - i)); // 180: i -> N-1-i
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_sizes() {
|
||||
let src = vec![0u8; 16];
|
||||
let mut small = vec![0u8; 4];
|
||||
assert!(!rotate_rgba8888(&src, 8, 2, 2, ROTATE_90, &mut small)); // dst too small
|
||||
assert!(!rotate_rgba8888(&src, 4, 2, 2, 1, &mut small)); // stride < sw*4
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! immich_native_core — shared Rust core for the immich server (napi) and mobile (dart:ffi).
|
||||
//!
|
||||
//! Pure logic only: no binding or platform deps live here. Each binding crate
|
||||
//! (`immich_core_dart`, `immich_core_napi`) is a thin wrapper. Capabilities are
|
||||
//! cargo features (`hashing`, `image`, ...) so every binding opts into the same set.
|
||||
|
||||
#[cfg(feature = "hashing")]
|
||||
pub mod hashing;
|
||||
|
||||
#[cfg(feature = "image")]
|
||||
pub mod image;
|
||||
|
||||
/// Version of the native core. Smoke-test entrypoint exercised by every binding.
|
||||
pub fn core_version() -> &'static str {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn version_is_present() {
|
||||
assert!(!core_version().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "immich_core_dart"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
# native_toolchain_rust requires cdylib (the bundled lib) + staticlib (iOS). It
|
||||
# derives the artifact name from [package].name, so no [lib] name override here.
|
||||
[lib]
|
||||
crate-type = ["cdylib", "staticlib"]
|
||||
|
||||
# hashing (SHA-1 asset identity) is the reason this lib exists — always on, so the
|
||||
# cbindgen header + ffigen bindings always match the exported symbols.
|
||||
[dependencies]
|
||||
immich_core = { path = "../immich_core", default-features = false, features = ["hashing", "image"] }
|
||||
|
||||
[build-dependencies]
|
||||
cbindgen = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,19 @@
|
||||
use std::path::Path;
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=src/lib.rs");
|
||||
println!("cargo:rerun-if-changed=cbindgen.toml");
|
||||
|
||||
let crate_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
|
||||
let out = Path::new(&crate_dir).join("include").join("immich_core.h");
|
||||
std::fs::create_dir_all(out.parent().unwrap()).ok();
|
||||
|
||||
// Hard-fail, not a warning: the CI drift gate diffs this header, so a silent
|
||||
// codegen failure would let a stale header sail through green.
|
||||
match cbindgen::generate(&crate_dir) {
|
||||
Ok(bindings) => {
|
||||
bindings.write_to_file(&out);
|
||||
}
|
||||
Err(e) => panic!("cbindgen failed: {e}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
language = "C"
|
||||
pragma_once = true
|
||||
autogen_warning = "// Generated by cbindgen — do not edit."
|
||||
@@ -0,0 +1,60 @@
|
||||
#pragma once
|
||||
|
||||
// Generated by cbindgen — do not edit.
|
||||
|
||||
#include <stdarg.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
/**
|
||||
* Native core version as a NUL-terminated UTF-8 string.
|
||||
* Free the result with [`immich_core_free_string`].
|
||||
*/
|
||||
char *immich_core_version(void);
|
||||
|
||||
/**
|
||||
* SHA-1 (lowercase hex) of `len` bytes at `ptr`. Returns NULL on a null pointer.
|
||||
* Free the result with [`immich_core_free_string`].
|
||||
*
|
||||
* # Safety
|
||||
* `ptr` must be valid for reads of `len` bytes.
|
||||
*/
|
||||
char *immich_core_sha1_hex(const unsigned char *ptr, uintptr_t len);
|
||||
|
||||
/**
|
||||
* SHA-1 (lowercase hex) of the file at `path` (NUL-terminated UTF-8), read via
|
||||
* mmap — no Dart-side read or copy. Returns NULL on a null path, non-UTF-8 path,
|
||||
* or any IO error. Free the result with [`immich_core_free_string`].
|
||||
*
|
||||
* # Safety
|
||||
* `path` must be a valid NUL-terminated C string, or null.
|
||||
*/
|
||||
char *immich_core_sha1_file(const char *path);
|
||||
|
||||
/**
|
||||
* Rotate an RGBA8888 image to the given EXIF `orientation`. `src` is `sh` rows of
|
||||
* `src_stride` bytes; `dst` is the caller's densely-packed `dw*dh*4` output (dims
|
||||
* swap for 90/270/transpose). Returns false (a safe no-op) on null pointers or
|
||||
* inconsistent sizes so the caller can fall back. The platform side owns the
|
||||
* bitmap lock + the dst allocation; this only fills dst.
|
||||
*
|
||||
* # Safety
|
||||
* `src` must be valid for reads of `src_len` bytes and `dst` for writes of `dst_len`.
|
||||
*/
|
||||
bool immich_core_rotate_rgba8888(const uint8_t *src,
|
||||
uintptr_t src_len,
|
||||
uintptr_t src_stride,
|
||||
uint32_t width,
|
||||
uint32_t height,
|
||||
int32_t orientation,
|
||||
uint8_t *dst,
|
||||
uintptr_t dst_len);
|
||||
|
||||
/**
|
||||
* Release a string returned by this library.
|
||||
*
|
||||
* # Safety
|
||||
* `ptr` must be a pointer previously returned by this library, or null.
|
||||
*/
|
||||
void immich_core_free_string(char *ptr);
|
||||
@@ -0,0 +1,18 @@
|
||||
# The build hook (native_toolchain_rust) drives cargo via rustup and auto-installs
|
||||
# this toolchain + targets. Pin a version (never bare stable/beta) for reproducible
|
||||
# builds. Keep the channel in sync with mise.toml's rust pin.
|
||||
[toolchain]
|
||||
channel = "1.92.0"
|
||||
targets = [
|
||||
# Android
|
||||
"armv7-linux-androideabi",
|
||||
"aarch64-linux-android",
|
||||
"x86_64-linux-android",
|
||||
# iOS (device + simulator)
|
||||
"aarch64-apple-ios",
|
||||
"aarch64-apple-ios-sim",
|
||||
"x86_64-apple-ios",
|
||||
# host (local test / macOS)
|
||||
"aarch64-apple-darwin",
|
||||
"x86_64-apple-darwin",
|
||||
]
|
||||
@@ -0,0 +1,197 @@
|
||||
//! dart:ffi binding for immich_core (mobile).
|
||||
//!
|
||||
//! Returns heap-allocated C strings the caller must release with
|
||||
//! `immich_core_free_string`. cbindgen emits `include/immich_core.h` at build time.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use std::ffi::{c_char, CStr, CString};
|
||||
use std::os::raw::c_uchar;
|
||||
use std::ptr;
|
||||
|
||||
/// Native core version as a NUL-terminated UTF-8 string.
|
||||
/// Free the result with [`immich_core_free_string`].
|
||||
#[no_mangle]
|
||||
pub extern "C" fn immich_core_version() -> *mut c_char {
|
||||
guard(ptr::null_mut(), || {
|
||||
into_c_string(immich_core::core_version().to_owned())
|
||||
})
|
||||
}
|
||||
|
||||
/// SHA-1 (lowercase hex) of `len` bytes at `ptr`. Returns NULL on a null pointer.
|
||||
/// Free the result with [`immich_core_free_string`].
|
||||
///
|
||||
/// # Safety
|
||||
/// `ptr` must be valid for reads of `len` bytes.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn immich_core_sha1_hex(ptr: *const c_uchar, len: usize) -> *mut c_char {
|
||||
if ptr.is_null() {
|
||||
return ptr::null_mut();
|
||||
}
|
||||
// SAFETY: caller guarantees `ptr` is valid for reads of `len` bytes (see # Safety).
|
||||
let bytes = unsafe { std::slice::from_raw_parts(ptr, len) };
|
||||
guard(ptr::null_mut(), || {
|
||||
into_c_string(immich_core::hashing::sha1_hex(bytes))
|
||||
})
|
||||
}
|
||||
|
||||
/// SHA-1 (lowercase hex) of the file at `path` (NUL-terminated UTF-8), read via
|
||||
/// mmap — no Dart-side read or copy. Returns NULL on a null path, non-UTF-8 path,
|
||||
/// or any IO error. Free the result with [`immich_core_free_string`].
|
||||
///
|
||||
/// # Safety
|
||||
/// `path` must be a valid NUL-terminated C string, or null.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn immich_core_sha1_file(path: *const c_char) -> *mut c_char {
|
||||
if path.is_null() {
|
||||
return ptr::null_mut();
|
||||
}
|
||||
// SAFETY: caller guarantees `path` is a valid NUL-terminated C string (see # Safety).
|
||||
let cpath = unsafe { CStr::from_ptr(path) };
|
||||
guard(ptr::null_mut(), || match cpath.to_str() {
|
||||
Ok(s) => match immich_core::hashing::sha1_file(s) {
|
||||
Ok(hex) => into_c_string(hex),
|
||||
Err(_) => ptr::null_mut(),
|
||||
},
|
||||
Err(_) => ptr::null_mut(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Rotate an RGBA8888 image to the given EXIF `orientation`. `src` is `sh` rows of
|
||||
/// `src_stride` bytes; `dst` is the caller's densely-packed `dw*dh*4` output (dims
|
||||
/// swap for 90/270/transpose). Returns false (a safe no-op) on null pointers or
|
||||
/// inconsistent sizes so the caller can fall back. The platform side owns the
|
||||
/// bitmap lock + the dst allocation; this only fills dst.
|
||||
///
|
||||
/// # Safety
|
||||
/// `src` must be valid for reads of `src_len` bytes and `dst` for writes of `dst_len`.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn immich_core_rotate_rgba8888(
|
||||
src: *const u8,
|
||||
src_len: usize,
|
||||
src_stride: usize,
|
||||
width: u32,
|
||||
height: u32,
|
||||
orientation: i32,
|
||||
dst: *mut u8,
|
||||
dst_len: usize,
|
||||
) -> bool {
|
||||
if src.is_null() || dst.is_null() {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: caller guarantees `src` is valid for reads of `src_len` bytes (see # Safety).
|
||||
let src_slice = unsafe { std::slice::from_raw_parts(src, src_len) };
|
||||
// SAFETY: caller guarantees `dst` is valid for writes of `dst_len` bytes (see # Safety).
|
||||
let dst_slice = unsafe { std::slice::from_raw_parts_mut(dst, dst_len) };
|
||||
// AssertUnwindSafe: the closure writes through `&mut dst_slice`, which isn't
|
||||
// UnwindSafe, but a panic mid-rotate only leaves dst partially written — not a
|
||||
// broken invariant — and we return false so the caller discards the buffer.
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
immich_core::image::rotate_rgba8888(
|
||||
src_slice,
|
||||
src_stride,
|
||||
width as usize,
|
||||
height as usize,
|
||||
orientation,
|
||||
dst_slice,
|
||||
)
|
||||
}))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Release a string returned by this library.
|
||||
///
|
||||
/// # Safety
|
||||
/// `ptr` must be a pointer previously returned by this library, or null.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn immich_core_free_string(ptr: *mut c_char) {
|
||||
if ptr.is_null() {
|
||||
return;
|
||||
}
|
||||
guard((), || {
|
||||
// SAFETY: `ptr` came from this library's `CString::into_raw` (see # Safety).
|
||||
let s = unsafe { CString::from_raw(ptr) };
|
||||
drop(s);
|
||||
});
|
||||
}
|
||||
|
||||
/// Run `f` at the FFI boundary, turning a panic into `sentinel` rather than
|
||||
/// unwinding across `extern "C"` into the host. Guards panics only — a bad `len`
|
||||
/// or a double/foreign free is caller-contract UB that stays the caller's
|
||||
/// `# Safety` obligation, not something this can catch.
|
||||
fn guard<T>(sentinel: T, f: impl FnOnce() -> T + std::panic::UnwindSafe) -> T {
|
||||
std::panic::catch_unwind(f).unwrap_or(sentinel)
|
||||
}
|
||||
|
||||
fn into_c_string(s: String) -> *mut c_char {
|
||||
match CString::new(s) {
|
||||
Ok(c) => c.into_raw(),
|
||||
Err(_) => ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
use std::ffi::CStr;
|
||||
|
||||
#[test]
|
||||
fn version_roundtrips_and_frees() {
|
||||
let p = immich_core_version();
|
||||
assert!(!p.is_null());
|
||||
// SAFETY: `p` is a non-null NUL-terminated string from this library.
|
||||
let s = unsafe { CStr::from_ptr(p) }.to_str().unwrap();
|
||||
assert!(!s.is_empty());
|
||||
// SAFETY: `p` was returned by this library and is freed exactly once.
|
||||
unsafe { immich_core_free_string(p) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha1_null_ptr_returns_null() {
|
||||
// SAFETY: a null ptr is the documented null-returning case.
|
||||
let p = unsafe { immich_core_sha1_hex(ptr::null(), 0) };
|
||||
assert!(p.is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha1_known_vector_roundtrips_and_frees() {
|
||||
let input = b"abc";
|
||||
// SAFETY: `input` is valid for reads of `input.len()` bytes.
|
||||
let p = unsafe { immich_core_sha1_hex(input.as_ptr(), input.len()) };
|
||||
assert!(!p.is_null());
|
||||
// SAFETY: `p` is a non-null NUL-terminated string from this library.
|
||||
let s = unsafe { CStr::from_ptr(p) }.to_str().unwrap();
|
||||
assert_eq!(s, "a9993e364706816aba3e25717850c26c9cd0d89d");
|
||||
// SAFETY: `p` was returned by this library and is freed exactly once.
|
||||
unsafe { immich_core_free_string(p) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_null_is_noop() {
|
||||
// SAFETY: free_string explicitly accepts null.
|
||||
unsafe { immich_core_free_string(ptr::null_mut()) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha1_file_roundtrips_and_frees() {
|
||||
let path = std::env::temp_dir().join(format!("immich_core_ffi_{}.bin", std::process::id()));
|
||||
std::fs::write(&path, b"abc").unwrap();
|
||||
let c = std::ffi::CString::new(path.to_str().unwrap()).unwrap();
|
||||
// SAFETY: `c` is a valid NUL-terminated path string.
|
||||
let p = unsafe { immich_core_sha1_file(c.as_ptr()) };
|
||||
assert!(!p.is_null());
|
||||
// SAFETY: `p` is a non-null string from this library.
|
||||
let s = unsafe { CStr::from_ptr(p) }.to_str().unwrap();
|
||||
assert_eq!(s, "a9993e364706816aba3e25717850c26c9cd0d89d");
|
||||
// SAFETY: `p` was returned by this library, freed once.
|
||||
unsafe { immich_core_free_string(p) };
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha1_file_null_returns_null() {
|
||||
// SAFETY: a null path is the documented null-returning case.
|
||||
let p = unsafe { immich_core_sha1_file(ptr::null()) };
|
||||
assert!(p.is_null());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "immich_core_napi"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
immich_core = { path = "../immich_core", default-features = false, features = ["hashing"] }
|
||||
napi = { workspace = true, features = ["napi4", "dyn-symbols"] }
|
||||
napi-derive = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
napi-build = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
napi_build::setup();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//! napi-rs binding for immich_core (node server).
|
||||
//!
|
||||
//! Built as a cdylib loaded as a `.node` addon — the same shape as the server's
|
||||
//! existing native deps (sharp, bcrypt).
|
||||
|
||||
use napi_derive::napi;
|
||||
|
||||
/// Native core version. JS: `core.coreVersion()`.
|
||||
#[napi]
|
||||
pub fn core_version() -> String {
|
||||
immich_core::core_version().to_owned()
|
||||
}
|
||||
|
||||
/// SHA-1 (lowercase hex) of a buffer. JS: `core.sha1Hex(Buffer.from(...))`.
|
||||
#[napi]
|
||||
pub fn sha1_hex(bytes: napi::bindgen_prelude::Buffer) -> String {
|
||||
immich_core::hashing::sha1_hex(bytes.as_ref())
|
||||
}
|
||||
|
||||
/// SHA-1 (lowercase hex) of a file, read via mmap. JS: `core.sha1File(path)`.
|
||||
#[napi]
|
||||
pub fn sha1_file(path: String) -> napi::Result<String> {
|
||||
immich_core::hashing::sha1_file(&path).map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
/pubspec.lock
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
/build/
|
||||
/coverage/
|
||||
@@ -0,0 +1,48 @@
|
||||
# immich_native_core (Flutter package)
|
||||
|
||||
dart:ffi bindings to the `immich_native_core` Rust core. The native code is **built
|
||||
from source on every app build** via a Dart build hook (Flutter native assets) — no
|
||||
prebuilt binaries, no `DynamicLibrary`, no platform plugin glue.
|
||||
|
||||
## Use it from immich/mobile
|
||||
|
||||
```yaml
|
||||
# mobile/pubspec.yaml
|
||||
dependencies:
|
||||
immich_native_core:
|
||||
path: ../native/immich_native_core
|
||||
```
|
||||
|
||||
`dart pub get`, then call it:
|
||||
|
||||
```dart
|
||||
import 'package:immich_native_core/immich_native_core.dart';
|
||||
|
||||
final version = coreVersion();
|
||||
final hex = sha1Hex(bytes); // hash large inputs off the main isolate (worker_manager)
|
||||
```
|
||||
|
||||
No app-level Gradle/Podfile edits. `hook/build.dart` compiles the Rust crate and
|
||||
Flutter bundles it as a code asset; the `@Native` bindings resolve against it.
|
||||
**Requirement:** every machine that builds the app needs [rustup](https://rustup.rs)
|
||||
— the hook auto-installs the pinned toolchain + targets from the crate's
|
||||
`rust-toolchain.toml`.
|
||||
|
||||
## Layout
|
||||
|
||||
- `hook/build.dart` — builds `../crates/immich_core_dart` via `native_toolchain_rust`.
|
||||
- `lib/immich_native_core.dart` — barrel, the public API.
|
||||
- `lib/src/{core,hashing,image}.dart` — thin wrappers, one file per Rust module.
|
||||
- `lib/src/ffi/bindings.g.dart` — ffigen `@Native` output (committed; do not edit).
|
||||
- `ffigen.yaml` — ffi-native mode; asset-id must match the hook's `assetName`.
|
||||
- `test/` — host FFI roundtrip (`flutter test`); device runs via `mobile/integration_test`.
|
||||
|
||||
## ⚠ iOS App Extensions
|
||||
|
||||
Code assets are bundled into the app's **Runner** target. immich ships a Share
|
||||
Extension and a Widget Extension — if the core is ever called from one of those,
|
||||
verify the symbols resolve there (same family as the embed-into-Runner-only gotcha).
|
||||
Not an issue while only the main app calls it.
|
||||
|
||||
The Rust workspace, the codegen/build/test commands, and the "add a function" loop
|
||||
live in [`../README.md`](../README.md).
|
||||
@@ -0,0 +1,4 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
@@ -0,0 +1,20 @@
|
||||
# Regenerate: `mise run codegen` (cbindgen header -> ffigen @Native bindings).
|
||||
# ffi-native mode emits top-level @Native externals + a library @DefaultAsset
|
||||
# pointing at the code asset hook/build.dart produces — no DynamicLibrary loader.
|
||||
# asset-id MUST equal the generated file's package URI (and the hook's assetName).
|
||||
name: ImmichNativeCoreBindings
|
||||
ffi-native:
|
||||
asset-id: 'package:immich_native_core/src/ffi/bindings.g.dart'
|
||||
description: 'FFI bindings to immich_native_core — generated, do not edit.'
|
||||
output: 'lib/src/ffi/bindings.g.dart'
|
||||
headers:
|
||||
entry-points:
|
||||
- '../crates/immich_core_dart/include/immich_core.h'
|
||||
include-directives:
|
||||
- '**/immich_core.h'
|
||||
functions:
|
||||
include:
|
||||
- 'immich_core_.*'
|
||||
comments:
|
||||
style: any
|
||||
length: full
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'package:hooks/hooks.dart';
|
||||
import 'package:native_toolchain_rust/native_toolchain_rust.dart';
|
||||
|
||||
// Builds crates/immich_core_dart from source on every app build and bundles it as
|
||||
// a code asset. assetName must match the ffigen output (its package URI is the
|
||||
// @Native DefaultAsset id). The crate is a sibling, so point cratePath at it.
|
||||
void main(List<String> args) async {
|
||||
await build(args, (input, output) async {
|
||||
await RustBuilder(
|
||||
assetName: 'src/ffi/bindings.g.dart',
|
||||
cratePath: '../crates/immich_core_dart',
|
||||
).run(input: input, output: output);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/// dart:ffi bindings to the immich_native_core Rust core (built from source via
|
||||
/// Dart build hooks). Public API only — implementation lives in `src/`, organised
|
||||
/// to mirror the Rust crate's modules (core / hashing / image).
|
||||
library;
|
||||
|
||||
export 'src/core.dart';
|
||||
export 'src/hashing.dart';
|
||||
export 'src/image.dart';
|
||||
@@ -0,0 +1,5 @@
|
||||
import 'ffi/bindings.g.dart' as bindings;
|
||||
import 'ffi/ffi.dart';
|
||||
|
||||
/// Version baked into the native core. Cheap — fine on the main isolate.
|
||||
String coreVersion() => readAndFree(bindings.immich_core_version(), 'core_version');
|
||||
@@ -0,0 +1,75 @@
|
||||
// AUTO GENERATED FILE, DO NOT EDIT.
|
||||
//
|
||||
// Generated by `package:ffigen`.
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
@ffi.DefaultAsset('package:immich_native_core/src/ffi/bindings.g.dart')
|
||||
library;
|
||||
|
||||
import 'dart:ffi' as ffi;
|
||||
|
||||
/// Native core version as a NUL-terminated UTF-8 string.
|
||||
/// Free the result with [`immich_core_free_string`].
|
||||
@ffi.Native<ffi.Pointer<ffi.Char> Function()>()
|
||||
external ffi.Pointer<ffi.Char> immich_core_version();
|
||||
|
||||
/// SHA-1 (lowercase hex) of `len` bytes at `ptr`. Returns NULL on a null pointer.
|
||||
/// Free the result with [`immich_core_free_string`].
|
||||
///
|
||||
/// # Safety
|
||||
/// `ptr` must be valid for reads of `len` bytes.
|
||||
@ffi.Native<
|
||||
ffi.Pointer<ffi.Char> Function(ffi.Pointer<ffi.UnsignedChar>, ffi.UintPtr)
|
||||
>()
|
||||
external ffi.Pointer<ffi.Char> immich_core_sha1_hex(
|
||||
ffi.Pointer<ffi.UnsignedChar> ptr,
|
||||
int len,
|
||||
);
|
||||
|
||||
/// SHA-1 (lowercase hex) of the file at `path` (NUL-terminated UTF-8), read via
|
||||
/// mmap — no Dart-side read or copy. Returns NULL on a null path, non-UTF-8 path,
|
||||
/// or any IO error. Free the result with [`immich_core_free_string`].
|
||||
///
|
||||
/// # Safety
|
||||
/// `path` must be a valid NUL-terminated C string, or null.
|
||||
@ffi.Native<ffi.Pointer<ffi.Char> Function(ffi.Pointer<ffi.Char>)>()
|
||||
external ffi.Pointer<ffi.Char> immich_core_sha1_file(
|
||||
ffi.Pointer<ffi.Char> path,
|
||||
);
|
||||
|
||||
/// Rotate an RGBA8888 image to the given EXIF `orientation`. `src` is `sh` rows of
|
||||
/// `src_stride` bytes; `dst` is the caller's densely-packed `dw*dh*4` output (dims
|
||||
/// swap for 90/270/transpose). Returns false (a safe no-op) on null pointers or
|
||||
/// inconsistent sizes so the caller can fall back. The platform side owns the
|
||||
/// bitmap lock + the dst allocation; this only fills dst.
|
||||
///
|
||||
/// # Safety
|
||||
/// `src` must be valid for reads of `src_len` bytes and `dst` for writes of `dst_len`.
|
||||
@ffi.Native<
|
||||
ffi.Bool Function(
|
||||
ffi.Pointer<ffi.Uint8>,
|
||||
ffi.UintPtr,
|
||||
ffi.UintPtr,
|
||||
ffi.Uint32,
|
||||
ffi.Uint32,
|
||||
ffi.Int32,
|
||||
ffi.Pointer<ffi.Uint8>,
|
||||
ffi.UintPtr,
|
||||
)
|
||||
>()
|
||||
external bool immich_core_rotate_rgba8888(
|
||||
ffi.Pointer<ffi.Uint8> src,
|
||||
int src_len,
|
||||
int src_stride,
|
||||
int width,
|
||||
int height,
|
||||
int orientation,
|
||||
ffi.Pointer<ffi.Uint8> dst,
|
||||
int dst_len,
|
||||
);
|
||||
|
||||
/// Release a string returned by this library.
|
||||
///
|
||||
/// # Safety
|
||||
/// `ptr` must be a pointer previously returned by this library, or null.
|
||||
@ffi.Native<ffi.Void Function(ffi.Pointer<ffi.Char>)>()
|
||||
external void immich_core_free_string(ffi.Pointer<ffi.Char> ptr);
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'dart:ffi';
|
||||
|
||||
import 'package:ffi/ffi.dart';
|
||||
|
||||
import 'bindings.g.dart' as bindings;
|
||||
|
||||
/// Read a C string the core returned into a Dart string and free it. A null
|
||||
/// return means the native call failed (panic caught at the boundary, or error),
|
||||
/// so we throw rather than hand back a silent empty value.
|
||||
String readAndFree(Pointer<Char> ptr, String op) {
|
||||
if (ptr == nullptr) {
|
||||
throw StateError('immich_native_core: $op returned null');
|
||||
}
|
||||
try {
|
||||
return ptr.cast<Utf8>().toDartString();
|
||||
} finally {
|
||||
bindings.immich_core_free_string(ptr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'dart:ffi';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:ffi/ffi.dart';
|
||||
|
||||
import 'ffi/bindings.g.dart' as bindings;
|
||||
import 'ffi/ffi.dart';
|
||||
|
||||
/// Lowercase-hex SHA-1 of [bytes]. Reads every byte natively and blocks the
|
||||
/// calling thread, so hash large inputs off the main isolate.
|
||||
String sha1Hex(Uint8List bytes) {
|
||||
// allocate at least 1 byte — malloc(0) may return null (allocator-defined),
|
||||
// which package:ffi would reject. The native side still reads only [len] bytes.
|
||||
final len = bytes.length;
|
||||
final buf = malloc<Uint8>(len == 0 ? 1 : len);
|
||||
try {
|
||||
if (len > 0) buf.asTypedList(len).setAll(0, bytes);
|
||||
return readAndFree(bindings.immich_core_sha1_hex(buf.cast(), len), 'sha1_hex');
|
||||
} finally {
|
||||
malloc.free(buf);
|
||||
}
|
||||
}
|
||||
|
||||
/// Lowercase-hex SHA-1 of the file at [path], hashed natively via mmap — the file
|
||||
/// is never read into the Dart heap. Blocks the calling thread, so hash large
|
||||
/// files off the main isolate. Throws if the file is missing/unreadable.
|
||||
String sha1File(String path) {
|
||||
final cpath = path.toNativeUtf8();
|
||||
try {
|
||||
return readAndFree(bindings.immich_core_sha1_file(cpath.cast()), 'sha1_file');
|
||||
} finally {
|
||||
malloc.free(cpath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'dart:ffi';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:ffi/ffi.dart';
|
||||
|
||||
import 'ffi/bindings.g.dart' as bindings;
|
||||
|
||||
/// True if [orientation] (EXIF) swaps width and height (the 90/270/transpose family).
|
||||
bool orientationSwapsDims(int orientation) =>
|
||||
orientation == 5 || orientation == 6 || orientation == 7 || orientation == 8;
|
||||
|
||||
/// Rotate an RGBA8888 image to the given EXIF [orientation], returning a freshly
|
||||
/// packed buffer (dims swap for 90/270/transpose). [srcStride] is bytes per source
|
||||
/// row (>= width*4). Returns null if the native rotate declines (bad sizes).
|
||||
///
|
||||
/// The production caller is the platform decode pipeline (it has the locked native
|
||||
/// bitmap); this Dart entry mirrors that path and is what the host tests exercise.
|
||||
Uint8List? rotateRgba8888(Uint8List src, int srcStride, int width, int height, int orientation) {
|
||||
final dw = orientationSwapsDims(orientation) ? height : width;
|
||||
final dh = orientationSwapsDims(orientation) ? width : height;
|
||||
final dstLen = dw * dh * 4;
|
||||
final srcPtr = malloc<Uint8>(src.isEmpty ? 1 : src.length);
|
||||
final dstPtr = malloc<Uint8>(dstLen == 0 ? 1 : dstLen);
|
||||
try {
|
||||
if (src.isNotEmpty) srcPtr.asTypedList(src.length).setAll(0, src);
|
||||
final ok = bindings.immich_core_rotate_rgba8888(
|
||||
srcPtr.cast(),
|
||||
src.length,
|
||||
srcStride,
|
||||
width,
|
||||
height,
|
||||
orientation,
|
||||
dstPtr.cast(),
|
||||
dstLen,
|
||||
);
|
||||
if (!ok) return null;
|
||||
return Uint8List.fromList(dstPtr.asTypedList(dstLen));
|
||||
} finally {
|
||||
malloc.free(srcPtr);
|
||||
malloc.free(dstPtr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
name: immich_native_core
|
||||
description: "dart:ffi bindings to the immich_native_core Rust core, built from source via Dart build hooks."
|
||||
version: 0.1.0
|
||||
homepage: https://github.com/immich-app/immich
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
sdk: '>=3.11.0 <4.0.0'
|
||||
flutter: '>=3.3.0'
|
||||
|
||||
# Not a platform plugin: the native lib is built + bundled by hook/build.dart as a
|
||||
# code asset (Flutter native assets), so there is no ffiPlugin / android / ios dir.
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
ffi: ^2.2.0
|
||||
# build-hook deps — run at build time to compile the Rust crate (need rustup).
|
||||
hooks: ^2.0.2
|
||||
native_toolchain_rust: ^1.0.4
|
||||
|
||||
dev_dependencies:
|
||||
ffigen: 20.1.1 # pinned exact — a caret bump can re-emit bindings
|
||||
crypto: ^3.0.7 # pure-Dart SHA-1 baseline for the perf bench only
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
@@ -0,0 +1,53 @@
|
||||
// Host FFI roundtrip — `flutter test` builds the hook for the host platform and
|
||||
// resolves the @Native symbols, no device needed. (Device runs: example/integration_test.)
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_native_core/immich_native_core.dart';
|
||||
|
||||
void main() {
|
||||
test('coreVersion returns a non-empty version', () {
|
||||
expect(coreVersion(), isNotEmpty);
|
||||
});
|
||||
|
||||
test('sha1Hex matches the FIPS-180 vector for "abc"', () {
|
||||
expect(
|
||||
sha1Hex(Uint8List.fromList(utf8.encode('abc'))),
|
||||
'a9993e364706816aba3e25717850c26c9cd0d89d',
|
||||
);
|
||||
});
|
||||
|
||||
test('sha1Hex of empty input', () {
|
||||
expect(sha1Hex(Uint8List(0)), 'da39a3ee5e6b4b0d3255bfef95601890afd80709');
|
||||
});
|
||||
|
||||
test('sha1File matches the in-memory hash (mmap path)', () {
|
||||
final tmp = Directory.systemTemp.createTempSync('native_core');
|
||||
final path = '${tmp.path}/abc.bin';
|
||||
File(path).writeAsBytesSync(utf8.encode('abc'));
|
||||
expect(sha1File(path), 'a9993e364706816aba3e25717850c26c9cd0d89d');
|
||||
tmp.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
test('sha1File throws on a missing file', () {
|
||||
expect(() => sha1File('/no/such/immich_native_core/file'), throwsStateError);
|
||||
});
|
||||
|
||||
test('rotateRgba8888: 180 reverses pixels, 90 swaps dims', () {
|
||||
// 2x1 image: pixel0 = red, pixel1 = green (RGBA).
|
||||
final src = Uint8List.fromList([255, 0, 0, 255, 0, 255, 0, 255]);
|
||||
final r180 = rotateRgba8888(src, 8, 2, 1, 3)!; // ROTATE_180
|
||||
expect(r180, [0, 255, 0, 255, 255, 0, 0, 255]); // green, red
|
||||
|
||||
final r90 = rotateRgba8888(src, 8, 2, 1, 6)!; // ROTATE_90 -> 1x2
|
||||
expect(r90.length, 8); // dims swapped to 1x2, still 2 pixels
|
||||
});
|
||||
|
||||
test('rotateRgba8888 returns null on an undersized result expectation', () {
|
||||
// width*height*4 mismatch is guarded natively; a 0x0 image yields empty.
|
||||
final empty = rotateRgba8888(Uint8List(0), 0, 0, 0, 1);
|
||||
expect(empty, anyOf(isNull, isEmpty));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// SHA-1 perf bench — run explicitly: `flutter test test/sha1_bench.dart`.
|
||||
// (Not named *_test.dart so it stays out of the default `flutter test` run.)
|
||||
//
|
||||
// Three ways to hash a file, to isolate where the win is:
|
||||
// A) sha1File(path) — Rust: open + mmap + HW-SHA, no Dart read
|
||||
// B) File.read + sha1Hex(bytes) — Dart reads into heap, Rust HW-SHA the bytes
|
||||
// C) File.read + crypto.sha1(bytes) — Dart reads into heap, pure-Dart SHA-1 (naive)
|
||||
// A vs B = mmap/zero-copy win; B vs C = HW-SHA vs pure-Dart; A vs C = total vs naive.
|
||||
//
|
||||
// IMPORTANT — C (pure-Dart) is NOT immich's real baseline. immich already hashes
|
||||
// assets natively + hardware-accelerated on BOTH platforms (Android Kotlin
|
||||
// MessageDigest SHA-1, iOS Swift CryptoKit Insecure.SHA1), streamed over a read
|
||||
// buffer, via pigeon. So the real-world comparison is A vs ~B (Rust mmap vs a
|
||||
// buffered native read with HW-SHA), i.e. roughly the A/B gap (~1.3x), NOT A/C.
|
||||
// ignore_for_file: avoid_print
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart' as crypto;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_native_core/immich_native_core.dart';
|
||||
|
||||
void main() {
|
||||
test('sha1 throughput: mmap(Rust) vs read+Rust vs read+pure-Dart', () {
|
||||
final tmp = Directory.systemTemp.createTempSync('sha1_bench');
|
||||
final sizesMb = [1, 16, 64, 256];
|
||||
|
||||
double msMin(int iters, void Function() f) {
|
||||
var best = double.infinity;
|
||||
for (var i = 0; i < iters; i++) {
|
||||
final sw = Stopwatch()..start();
|
||||
f();
|
||||
sw.stop();
|
||||
final ms = sw.elapsedMicroseconds / 1000.0;
|
||||
if (ms < best) best = ms;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
String mbps(int mb, double ms) => (mb / (ms / 1000.0)).toStringAsFixed(0);
|
||||
|
||||
print('');
|
||||
print('size │ A mmap(Rust) │ B read+Rust │ C read+pureDart │ A vs C');
|
||||
print('─────┼─────────────────┼─────────────────┼──────────────────┼───────');
|
||||
for (final mb in sizesMb) {
|
||||
final path = '${tmp.path}/f_$mb.bin';
|
||||
final chunk = Uint8List(1 << 20); // 1 MiB pattern
|
||||
for (var i = 0; i < chunk.length; i++) {
|
||||
chunk[i] = (i * 31 + 7) & 0xff;
|
||||
}
|
||||
final sink = File(path).openSync(mode: FileMode.write);
|
||||
for (var i = 0; i < mb; i++) {
|
||||
sink.writeFromSync(chunk);
|
||||
}
|
||||
sink.closeSync();
|
||||
|
||||
final bytes = File(path).readAsBytesSync(); // for B/C; also baked into their totals below
|
||||
final tRead = msMin(3, () => File(path).readAsBytesSync());
|
||||
|
||||
final tA = msMin(5, () => sha1File(path));
|
||||
final tB = tRead + msMin(5, () => sha1Hex(bytes));
|
||||
final tC = tRead + msMin(2, () => crypto.sha1.convert(bytes));
|
||||
|
||||
final hA = sha1File(path);
|
||||
final hB = sha1Hex(bytes);
|
||||
final hC = crypto.sha1.convert(bytes).toString();
|
||||
expect(hA, hB);
|
||||
expect(hA, hC);
|
||||
|
||||
final speedup = (tC / tA).toStringAsFixed(1);
|
||||
String cell(double ms, int mb) =>
|
||||
'${ms.toStringAsFixed(1)}ms ${mbps(mb, ms).padLeft(5)}MB/s';
|
||||
print(
|
||||
'${mb.toString().padLeft(3)}M │ ${cell(tA, mb)} │ ${cell(tB, mb)} │ ${cell(tC, mb).padRight(16)} │ ${speedup}x',
|
||||
);
|
||||
}
|
||||
print('(A/B/C identical SHA-1; read time included in B/C totals.)');
|
||||
print('(NOTE: immich already hashes natively+HW on both platforms — real');
|
||||
print(' baseline ~= B, not C. Rust mmap edge over it is the A/B gap (~1.3x).)');
|
||||
tmp.deleteSync(recursive: true);
|
||||
}, timeout: const Timeout(Duration(minutes: 10)));
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
[tools]
|
||||
rust = "1.92.0" # keep in sync with rust-toolchain.toml (the build hook uses rustup)
|
||||
|
||||
[tasks.build]
|
||||
description = "Build all native core crates (host)"
|
||||
run = "cargo build --workspace"
|
||||
|
||||
[tasks.test]
|
||||
description = "Run native core Rust tests"
|
||||
run = "cargo test --workspace"
|
||||
|
||||
[tasks.fmt]
|
||||
description = "Format all crates"
|
||||
run = "cargo fmt --all"
|
||||
|
||||
[tasks.lint]
|
||||
description = "Clippy (warnings = errors)"
|
||||
run = "cargo clippy --workspace --all-targets -- -D warnings"
|
||||
|
||||
# Regen the committed cbindgen header + ffigen @Native bindings.
|
||||
[tasks."codegen:ffigen"]
|
||||
alias = "codegen"
|
||||
description = "Generate the C header (cbindgen) + Dart @Native bindings (ffigen)"
|
||||
sources = [
|
||||
"crates/immich_core_dart/src/lib.rs",
|
||||
"crates/immich_core_dart/cbindgen.toml",
|
||||
"immich_native_core/ffigen.yaml",
|
||||
]
|
||||
outputs = [
|
||||
"crates/immich_core_dart/include/immich_core.h",
|
||||
"immich_native_core/lib/immich_native_core_bindings_generated.dart",
|
||||
]
|
||||
run = [
|
||||
"cargo build -p immich_core_dart",
|
||||
"cd immich_native_core && dart run ffigen --config ffigen.yaml && dart format lib/immich_native_core_bindings_generated.dart",
|
||||
]
|
||||
|
||||
# Host FFI roundtrip through the real build hook — no device. Builds the Rust crate
|
||||
# via rustup + resolves the @Native code asset.
|
||||
[tasks."test:flutter"]
|
||||
description = "Host FFI roundtrip via the build hook (flutter test)"
|
||||
dir = "immich_native_core"
|
||||
run = "flutter test"
|
||||
|
||||
[tasks."build:dart"]
|
||||
description = "Build the dart:ffi cdylib directly (host, raw cargo)"
|
||||
run = "cargo build -p immich_core_dart"
|
||||
|
||||
[tasks."build:napi"]
|
||||
description = "Build the node addon + stage a .node for require() (server, unwired)"
|
||||
run = [
|
||||
"cargo build -p immich_core_napi --release",
|
||||
"cp target/release/libimmich_core_napi.dylib smoke/immich_core_napi.node 2>/dev/null || cp target/release/libimmich_core_napi.so smoke/immich_core_napi.node",
|
||||
]
|
||||
|
||||
[tasks."smoke:dart"]
|
||||
description = "Host dart:ffi ABI roundtrip (raw DynamicLibrary on the cdylib)"
|
||||
depends = ["build:dart"]
|
||||
run = "dart run smoke/dart_smoke.dart target/debug/libimmich_core_dart.dylib"
|
||||
|
||||
[tasks."smoke:node"]
|
||||
description = "Host napi roundtrip"
|
||||
depends = ["build:napi"]
|
||||
run = "node smoke/node_smoke.mjs"
|
||||
|
||||
[tasks.smoke]
|
||||
description = "Rust tests + host dart:ffi + host napi roundtrips"
|
||||
depends = ["test", "smoke:dart", "smoke:node"]
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# Cross-build the napi addon for Linux server (x86_64 + aarch64) via zigbuild
|
||||
# (no Docker) and stage as .node under dist/server/<target>/.
|
||||
# In CI you'd build these natively per-arch instead; this is local convenience.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
CRATE=immich_core_napi
|
||||
|
||||
for t in x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu; do
|
||||
rustup target add "$t" >/dev/null 2>&1 || true
|
||||
cargo zigbuild -p "$CRATE" --target "$t" --release
|
||||
mkdir -p "dist/server/$t"
|
||||
cp "target/$t/release/lib${CRATE}.so" "dist/server/$t/immich_core_napi.node"
|
||||
done
|
||||
|
||||
echo "linux -> dist/server/*/immich_core_napi.node"
|
||||
@@ -0,0 +1,31 @@
|
||||
// Mobile-side roundtrip: open the dart:ffi cdylib and call into the shared core.
|
||||
// Standalone script (no package:ffi dep) — reads the returned C string by hand.
|
||||
//
|
||||
// dart run smoke/dart_smoke.dart target/debug/libimmich_core_dart.dylib
|
||||
|
||||
import 'dart:ffi';
|
||||
|
||||
typedef _VersionNative = Pointer<Uint8> Function();
|
||||
typedef _FreeNative = Void Function(Pointer<Uint8>);
|
||||
typedef _FreeDart = void Function(Pointer<Uint8>);
|
||||
|
||||
String _readCString(Pointer<Uint8> p) {
|
||||
final bytes = <int>[];
|
||||
for (var i = 0; p[i] != 0; i++) {
|
||||
bytes.add(p[i]);
|
||||
}
|
||||
return String.fromCharCodes(bytes);
|
||||
}
|
||||
|
||||
void main(List<String> args) {
|
||||
final libPath = args.isNotEmpty ? args.first : 'target/debug/libimmich_core_dart.dylib';
|
||||
final lib = DynamicLibrary.open(libPath);
|
||||
|
||||
final version = lib.lookupFunction<_VersionNative, _VersionNative>('immich_core_version');
|
||||
final free = lib.lookupFunction<_FreeNative, _FreeDart>('immich_core_free_string');
|
||||
|
||||
final ptr = version();
|
||||
print('DART core_version = ${_readCString(ptr)}');
|
||||
free(ptr);
|
||||
print('DART roundtrip OK');
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Server-side roundtrip: load the napi addon and call into the shared core.
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const core = require('./immich_core_napi.node');
|
||||
|
||||
const version = core.coreVersion();
|
||||
console.log(`NAPI core_version = ${version}`);
|
||||
|
||||
const hash = core.sha1Hex(Buffer.from('abc'));
|
||||
console.log(`NAPI sha1Hex("abc") = ${hash}`);
|
||||
|
||||
if (hash !== 'a9993e364706816aba3e25717850c26c9cd0d89d') {
|
||||
console.error('NAPI sha1 mismatch');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('NAPI roundtrip OK');
|
||||
@@ -21991,27 +21991,6 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RecentlyAddedResponse": {
|
||||
"properties": {
|
||||
"sidebarWeb": {
|
||||
"description": "Whether the recently added page appears in the web sidebar",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"sidebarWeb"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RecentlyAddedUpdate": {
|
||||
"properties": {
|
||||
"sidebarWeb": {
|
||||
"description": "Whether the recently added page appears in the web sidebar",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ReleaseChannel": {
|
||||
"description": "Release channel",
|
||||
"enum": [
|
||||
@@ -27546,9 +27525,6 @@
|
||||
"ratings": {
|
||||
"$ref": "#/components/schemas/RatingsResponse"
|
||||
},
|
||||
"recentlyAdded": {
|
||||
"$ref": "#/components/schemas/RecentlyAddedResponse"
|
||||
},
|
||||
"sharedLinks": {
|
||||
"$ref": "#/components/schemas/SharedLinksResponse"
|
||||
},
|
||||
@@ -27566,7 +27542,6 @@
|
||||
"people",
|
||||
"purchase",
|
||||
"ratings",
|
||||
"recentlyAdded",
|
||||
"sharedLinks",
|
||||
"tags"
|
||||
],
|
||||
@@ -27604,9 +27579,6 @@
|
||||
"ratings": {
|
||||
"$ref": "#/components/schemas/RatingsUpdate"
|
||||
},
|
||||
"recentlyAdded": {
|
||||
"$ref": "#/components/schemas/RecentlyAddedUpdate"
|
||||
},
|
||||
"sharedLinks": {
|
||||
"$ref": "#/components/schemas/SharedLinksUpdate"
|
||||
},
|
||||
|
||||
@@ -342,10 +342,6 @@ export type RatingsResponse = {
|
||||
/** Whether ratings are enabled */
|
||||
enabled: boolean;
|
||||
};
|
||||
export type RecentlyAddedResponse = {
|
||||
/** Whether the recently added page appears in the web sidebar */
|
||||
sidebarWeb: boolean;
|
||||
};
|
||||
export type SharedLinksResponse = {
|
||||
/** Whether shared links are enabled */
|
||||
enabled: boolean;
|
||||
@@ -368,7 +364,6 @@ export type UserPreferencesResponseDto = {
|
||||
people: PeopleResponse;
|
||||
purchase: PurchaseResponse;
|
||||
ratings: RatingsResponse;
|
||||
recentlyAdded: RecentlyAddedResponse;
|
||||
sharedLinks: SharedLinksResponse;
|
||||
tags: TagsResponse;
|
||||
};
|
||||
@@ -426,10 +421,6 @@ export type RatingsUpdate = {
|
||||
/** Whether ratings are enabled */
|
||||
enabled?: boolean;
|
||||
};
|
||||
export type RecentlyAddedUpdate = {
|
||||
/** Whether the recently added page appears in the web sidebar */
|
||||
sidebarWeb?: boolean;
|
||||
};
|
||||
export type SharedLinksUpdate = {
|
||||
/** Whether shared links are enabled */
|
||||
enabled?: boolean;
|
||||
@@ -453,7 +444,6 @@ export type UserPreferencesUpdateDto = {
|
||||
people?: PeopleUpdate;
|
||||
purchase?: PurchaseUpdate;
|
||||
ratings?: RatingsUpdate;
|
||||
recentlyAdded?: RecentlyAddedUpdate;
|
||||
sharedLinks?: SharedLinksUpdate;
|
||||
tags?: TagsUpdate;
|
||||
};
|
||||
|
||||
@@ -98,13 +98,6 @@ const CastUpdateSchema = z
|
||||
.optional()
|
||||
.meta({ id: 'CastUpdate' });
|
||||
|
||||
const RecentlyAddedUpdateSchema = z
|
||||
.object({
|
||||
sidebarWeb: z.boolean().optional().describe('Whether the recently added page appears in the web sidebar'),
|
||||
})
|
||||
.optional()
|
||||
.meta({ id: 'RecentlyAddedUpdate' });
|
||||
|
||||
const UserPreferencesUpdateSchema = z
|
||||
.object({
|
||||
albums: AlbumsUpdateSchema,
|
||||
@@ -119,7 +112,6 @@ const UserPreferencesUpdateSchema = z
|
||||
ratings: RatingsUpdateSchema,
|
||||
sharedLinks: SharedLinksUpdateSchema,
|
||||
tags: TagsUpdateSchema,
|
||||
recentlyAdded: RecentlyAddedUpdateSchema,
|
||||
})
|
||||
.meta({ id: 'UserPreferencesUpdateDto' });
|
||||
|
||||
@@ -199,12 +191,6 @@ const CastResponseSchema = z
|
||||
})
|
||||
.meta({ id: 'CastResponse' });
|
||||
|
||||
const RecentlyAddedResponseSchema = z
|
||||
.object({
|
||||
sidebarWeb: z.boolean().describe('Whether the recently added page appears in the web sidebar'),
|
||||
})
|
||||
.meta({ id: 'RecentlyAddedResponse' });
|
||||
|
||||
const UserPreferencesResponseSchema = z
|
||||
.object({
|
||||
albums: AlbumsResponseSchema,
|
||||
@@ -218,7 +204,6 @@ const UserPreferencesResponseSchema = z
|
||||
download: DownloadResponseSchema,
|
||||
purchase: PurchaseResponseSchema,
|
||||
cast: CastResponseSchema,
|
||||
recentlyAdded: RecentlyAddedResponseSchema,
|
||||
})
|
||||
.meta({ id: 'UserPreferencesResponseDto' });
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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] },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -619,9 +619,6 @@ export type UserPreferences = {
|
||||
cast: {
|
||||
gCastEnabled: boolean;
|
||||
};
|
||||
recentlyAdded: {
|
||||
sidebarWeb: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type UserMetadataItem<T extends keyof UserMetadata = UserMetadataKey> = {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -50,9 +50,6 @@ const getDefaultPreferences = (): UserPreferences => {
|
||||
cast: {
|
||||
gCastEnabled: false,
|
||||
},
|
||||
recentlyAdded: {
|
||||
sidebarWeb: false,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
mdiToolboxOutline,
|
||||
mdiTrashCan,
|
||||
mdiTrashCanOutline,
|
||||
mdiUploadOutline,
|
||||
} from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fly } from 'svelte/transition';
|
||||
@@ -84,14 +83,6 @@
|
||||
<NavbarItem title={$t('tags')} href={Route.tags()} icon={{ icon: mdiTagMultipleOutline, flipped: true }} />
|
||||
{/if}
|
||||
|
||||
{#if authManager.preferences.recentlyAdded.sidebarWeb}
|
||||
<NavbarItem
|
||||
title={$t('recently_added')}
|
||||
href={Route.recentlyAdded()}
|
||||
icon={{ icon: mdiUploadOutline, flipped: true }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if authManager.preferences.folders.enabled && authManager.preferences.folders.sidebarWeb}
|
||||
<NavbarItem title={$t('folders')} href={Route.folders()} icon={{ icon: mdiFolderOutline, flipped: true }} />
|
||||
{/if}
|
||||
|
||||
@@ -38,9 +38,6 @@
|
||||
// Cast
|
||||
let gCastEnabled = $state(authManager.preferences.cast?.gCastEnabled ?? false);
|
||||
|
||||
// Recently added
|
||||
let recentlyAddedSidebar = $state(authManager.preferences.recentlyAdded?.sidebarWeb ?? false);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const response = await updateMyPreferences({
|
||||
@@ -53,7 +50,6 @@
|
||||
sharedLinks: { enabled: sharedLinksEnabled, sidebarWeb: sharedLinkSidebar },
|
||||
tags: { enabled: tagsEnabled, sidebarWeb: tagsSidebar },
|
||||
cast: { gCastEnabled },
|
||||
recentlyAdded: { sidebarWeb: recentlyAddedSidebar },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -174,14 +170,6 @@
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="recentlyAdded" title={$t('recently_added')} subtitle={$t('recently_added_description')}>
|
||||
<div class="mt-4 flex flex-col gap-4 sm:ms-4">
|
||||
<Field label={$t('sidebar')} description={$t('sidebar_display_description')}>
|
||||
<Switch bind:checked={recentlyAddedSidebar} />
|
||||
</Field>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Button shape="round" type="submit" size="small" onclick={() => handleSave()}>{$t('save')}</Button>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,4 @@ export const preferencesFactory = Sync.makeFactory<UserPreferencesResponseDto>({
|
||||
enabled: false,
|
||||
sidebarWeb: false,
|
||||
},
|
||||
recentlyAdded: {
|
||||
sidebarWeb: false,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user