Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb8e242fbe | |||
| 5164feb5b4 | |||
| 56eb69b328 | |||
| ac74bca18b | |||
| e9d1951858 |
@@ -5,3 +5,4 @@
|
||||
/machine-learning/ @mertalev
|
||||
/e2e/ @danieldietzler
|
||||
/mobile/ @shenlong-tanwen @santoshakil
|
||||
/native/ @santoshakil @mertalev
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[
|
||||
{
|
||||
"label": "v3.0.0-rc.3",
|
||||
"url": "https://docs.v3.0.0-rc.3.archive.immich.app"
|
||||
"label": "v3.0.0-rc.4",
|
||||
"url": "https://docs.v3.0.0-rc.4.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.7.5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "3.0.0-rc.3",
|
||||
"version": "3.0.0-rc.4",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -1102,23 +1102,6 @@
|
||||
"favorite_action_prompt": "{count} added to Favorites",
|
||||
"favorite_or_unfavorite_photo": "Favorite or unfavorite photo",
|
||||
"favorites": "Favorites",
|
||||
"feature_message_non_destructive_editing_body": "Edit your photos freely — the original is always kept untouched.",
|
||||
"feature_message_non_destructive_editing_title": "Non-destructive editing",
|
||||
"feature_message_ocr_body": "Immich now reads the text inside your photos, so you can search for them by what they say.",
|
||||
"feature_message_ocr_title": "Search text in your photos",
|
||||
"feature_message_open_in_immich_body": "Set Immich as your gallery on Android to open photos straight from other apps.",
|
||||
"feature_message_open_in_immich_title": "Open photos in Immich",
|
||||
"feature_message_recently_added_body": "Jump straight to everything you've added lately on a dedicated page.",
|
||||
"feature_message_recently_added_title": "Recently added",
|
||||
"feature_message_settings_subtitle": "See what's new in version {version}",
|
||||
"feature_message_share_quality_body": "Press and hold the share button to choose the image quality before you share.",
|
||||
"feature_message_share_quality_title": "Choose your share quality",
|
||||
"feature_message_skip": "Skip",
|
||||
"feature_message_slideshow_body": "Sit back and watch your photos play in a full-screen slideshow.",
|
||||
"feature_message_slideshow_title": "Slideshow",
|
||||
"feature_message_upload_to_album_body": "Add photos directly into an album as you upload them.",
|
||||
"feature_message_upload_to_album_title": "Upload straight to an album",
|
||||
"feature_message_version": "Version {version}",
|
||||
"feature_photo_updated": "Feature photo updated",
|
||||
"features": "Features",
|
||||
"features_setting_description": "Manage the app features",
|
||||
@@ -2241,7 +2224,6 @@
|
||||
"week": "Week",
|
||||
"welcome": "Welcome",
|
||||
"welcome_to_immich": "Welcome to Immich",
|
||||
"whats_new": "What's new",
|
||||
"when": "When",
|
||||
"width": "Width",
|
||||
"wifi_name": "Wi-Fi Name",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "immich-ml"
|
||||
version = "3.0.0rc3"
|
||||
version = "3.0.0rc4"
|
||||
description = ""
|
||||
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
|
||||
requires-python = ">=3.11,<4.0"
|
||||
|
||||
@@ -974,7 +974,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "immich-ml"
|
||||
version = "3.0.0rc3"
|
||||
version = "3.0.0rc4"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiocache" },
|
||||
|
||||
@@ -22,8 +22,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 3051,
|
||||
"android.injected.version.name" => "3.0.0-rc.3",
|
||||
"android.injected.version.code" => 3052,
|
||||
"android.injected.version.name" => "3.0.0-rc.4",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab', track: 'beta')
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 3051,
|
||||
"android.injected.version.name" => "3.0.0-rc.3",
|
||||
"android.injected.version.code" => 3052,
|
||||
"android.injected.version.name" => "3.0.0-rc.4",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
|
Before Width: | Height: | Size: 502 B |
|
Before Width: | Height: | Size: 502 B |
|
Before Width: | Height: | Size: 504 B |
|
Before Width: | Height: | Size: 508 B |
|
Before Width: | Height: | Size: 500 B |
|
Before Width: | Height: | Size: 500 B |
|
Before Width: | Height: | Size: 500 B |
@@ -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';
|
||||
@@ -33,7 +32,6 @@ class AppConfig {
|
||||
final BackupConfig backup;
|
||||
final NetworkConfig network;
|
||||
final ShareConfig share;
|
||||
final FeatureMessageConfig featureMessage;
|
||||
|
||||
const AppConfig({
|
||||
this.logLevel = .info,
|
||||
@@ -48,7 +46,6 @@ class AppConfig {
|
||||
this.backup = const .new(),
|
||||
this.network = const .new(),
|
||||
this.share = const .new(),
|
||||
this.featureMessage = const .new(),
|
||||
});
|
||||
|
||||
AppConfig copyWith({
|
||||
@@ -64,7 +61,6 @@ class AppConfig {
|
||||
BackupConfig? backup,
|
||||
NetworkConfig? network,
|
||||
ShareConfig? share,
|
||||
FeatureMessageConfig? featureMessage,
|
||||
}) => .new(
|
||||
logLevel: logLevel ?? this.logLevel,
|
||||
theme: theme ?? this.theme,
|
||||
@@ -78,7 +74,6 @@ class AppConfig {
|
||||
backup: backup ?? this.backup,
|
||||
network: network ?? this.network,
|
||||
share: share ?? this.share,
|
||||
featureMessage: featureMessage ?? this.featureMessage,
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -96,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) {
|
||||
@@ -165,7 +146,6 @@ class AppConfig {
|
||||
.slideshowDuration => slideshow.duration,
|
||||
.slideshowLook => slideshow.look,
|
||||
.slideshowDirection => slideshow.direction,
|
||||
.featureMessageSeenVersion => featureMessage.seenVersion,
|
||||
})
|
||||
as T;
|
||||
|
||||
@@ -219,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)),
|
||||
.featureMessageSeenVersion => copyWith(featureMessage: featureMessage.copyWith(seenVersion: value as int)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
class FeatureMessageConfig {
|
||||
final int seenVersion;
|
||||
|
||||
const FeatureMessageConfig({this.seenVersion = 0});
|
||||
|
||||
FeatureMessageConfig copyWith({int? seenVersion}) =>
|
||||
FeatureMessageConfig(seenVersion: seenVersion ?? this.seenVersion);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) || (other is FeatureMessageConfig && other.seenVersion == seenVersion);
|
||||
|
||||
@override
|
||||
int get hashCode => seenVersion.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => 'FeatureMessageConfig(seenVersion: $seenVersion)';
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
class FeatureHighlight {
|
||||
final String image;
|
||||
final String titleKey;
|
||||
final String bodyKey;
|
||||
|
||||
const FeatureHighlight({required this.image, required this.titleKey, required this.bodyKey});
|
||||
}
|
||||
|
||||
const int featureMessageHighlightVersion = 1;
|
||||
|
||||
const String featureMessageReleaseLabel = '3.0.0';
|
||||
|
||||
const List<FeatureHighlight> featureMessageHighlights = [
|
||||
FeatureHighlight(
|
||||
image: 'assets/feature_message/share_quality.webp',
|
||||
titleKey: 'feature_message_share_quality_title',
|
||||
bodyKey: 'feature_message_share_quality_body',
|
||||
),
|
||||
FeatureHighlight(
|
||||
image: 'assets/feature_message/slideshow.webp',
|
||||
titleKey: 'feature_message_slideshow_title',
|
||||
bodyKey: 'feature_message_slideshow_body',
|
||||
),
|
||||
FeatureHighlight(
|
||||
image: 'assets/feature_message/recently_added.webp',
|
||||
titleKey: 'feature_message_recently_added_title',
|
||||
bodyKey: 'feature_message_recently_added_body',
|
||||
),
|
||||
FeatureHighlight(
|
||||
image: 'assets/feature_message/non_destructive_editing.webp',
|
||||
titleKey: 'feature_message_non_destructive_editing_title',
|
||||
bodyKey: 'feature_message_non_destructive_editing_body',
|
||||
),
|
||||
FeatureHighlight(
|
||||
image: 'assets/feature_message/ocr.webp',
|
||||
titleKey: 'feature_message_ocr_title',
|
||||
bodyKey: 'feature_message_ocr_body',
|
||||
),
|
||||
FeatureHighlight(
|
||||
image: 'assets/feature_message/open_in_immich.webp',
|
||||
titleKey: 'feature_message_open_in_immich_title',
|
||||
bodyKey: 'feature_message_open_in_immich_body',
|
||||
),
|
||||
FeatureHighlight(
|
||||
image: 'assets/feature_message/upload_to_album.webp',
|
||||
titleKey: 'feature_message_upload_to_album_title',
|
||||
bodyKey: 'feature_message_upload_to_album_body',
|
||||
),
|
||||
];
|
||||
@@ -73,10 +73,7 @@ enum SettingsKey<T> {
|
||||
slideshowRepeat<bool>(),
|
||||
slideshowDuration<int>(),
|
||||
slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)),
|
||||
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values)),
|
||||
|
||||
// Feature message
|
||||
featureMessageSeenVersion<int>();
|
||||
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values));
|
||||
|
||||
final _SettingsCodec<T>? _codecOverride;
|
||||
|
||||
|
||||
@@ -1,17 +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.read(SettingsKey.featureMessageSeenVersion);
|
||||
return featureMessageHighlights.isNotEmpty && featureMessageHighlightVersion > seen;
|
||||
}
|
||||
|
||||
Future<void> markSeen() =>
|
||||
_settingsRepository.write(SettingsKey.featureMessageSeenVersion, featureMessageHighlightVersion);
|
||||
}
|
||||
@@ -2,9 +2,7 @@ import 'package:auto_route/auto_route.dart';
|
||||
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/domain/models/feature_message.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/feature_message/feature_message_dialog.widget.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';
|
||||
@@ -89,14 +87,6 @@ class _MobileLayout extends StatelessWidget {
|
||||
],
|
||||
)
|
||||
.toList();
|
||||
settings.add(
|
||||
SettingsCard(
|
||||
icon: Icons.auto_awesome_outlined,
|
||||
title: 'whats_new'.tr(),
|
||||
subtitle: 'feature_message_settings_subtitle'.tr(namedArgs: {'version': featureMessageReleaseLabel}),
|
||||
onTap: () => showFeatureMessageDialog(context),
|
||||
),
|
||||
);
|
||||
return ListView(padding: const EdgeInsets.only(top: 10.0, bottom: 60), children: [...settings]);
|
||||
}
|
||||
}
|
||||
@@ -126,13 +116,6 @@ class _TabletLayout extends HookWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: ListTile(
|
||||
title: Text('whats_new'.tr()),
|
||||
leading: const Icon(Icons.auto_awesome_outlined),
|
||||
onTap: () => showFeatureMessageDialog(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -3,37 +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((_) {
|
||||
if (!mounted || ref.read(featureMessageCheckedProvider)) {
|
||||
return;
|
||||
}
|
||||
ref.read(featureMessageCheckedProvider.notifier).state = true;
|
||||
|
||||
final service = ref.read(featureMessageServiceProvider);
|
||||
// if (service.shouldShow()) {
|
||||
showFeatureMessageDialog(context).then((_) => service.markSeen());
|
||||
// }
|
||||
});
|
||||
}
|
||||
|
||||
@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()),
|
||||
|
||||
@@ -49,14 +49,21 @@ class BaseActionButton extends ConsumerWidget {
|
||||
|
||||
if (menuItem) {
|
||||
final iconColor = this.iconColor;
|
||||
final onPressed = this.onPressed;
|
||||
|
||||
return MenuItemButton(
|
||||
closeOnActivate: false,
|
||||
style: MenuItemButton.styleFrom(
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
),
|
||||
leadingIcon: Icon(iconData, color: iconColor, size: 20),
|
||||
onPressed: onPressed,
|
||||
onPressed: onPressed == null
|
||||
? null
|
||||
: () {
|
||||
onPressed();
|
||||
MenuController.maybeOf(context)?.close();
|
||||
},
|
||||
child: Text(label, style: TextStyle(fontSize: 15, color: iconColor)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
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';
|
||||
|
||||
Future<void> showFeatureMessageDialog(BuildContext context) {
|
||||
return showGeneralDialog<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
barrierDismissible: true,
|
||||
barrierLabel: 'whats_new'.tr(),
|
||||
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> {
|
||||
final PageController _controller = PageController();
|
||||
int _index = 0;
|
||||
|
||||
bool get _isLast => _index >= featureMessageHighlights.length - 1;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _advance() {
|
||||
if (_isLast) {
|
||||
Navigator.of(context).pop();
|
||||
return;
|
||||
}
|
||||
_controller.nextPage(duration: const Duration(milliseconds: 320), curve: Curves.easeOutCubic);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 64),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
backgroundColor: context.colorScheme.surface,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(24))),
|
||||
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('whats_new'.tr(), style: context.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'feature_message_version'.tr(namedArgs: {'version': featureMessageReleaseLabel}),
|
||||
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: PageView.builder(
|
||||
controller: _controller,
|
||||
itemCount: featureMessageHighlights.length,
|
||||
onPageChanged: (i) => setState(() => _index = i),
|
||||
itemBuilder: (_, index) => _FeaturePage(highlight: featureMessageHighlights[index]),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_PageDots(controller: _controller, index: _index, count: featureMessageHighlights.length),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
style: TextButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14)),
|
||||
child: Text('feature_message_skip'.tr()),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: _advance,
|
||||
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 14)),
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Text(_isLast ? 'ok'.tr() : 'next'.tr(), key: ValueKey(_isLast)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: ColoredBox(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 300,
|
||||
child: Image.asset(
|
||||
highlight.image,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, _, __) =>
|
||||
Center(child: Icon(Icons.auto_awesome_outlined, color: scheme.onSurfaceVariant, size: 56)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
highlight.bodyKey.tr(),
|
||||
style: context.textTheme.bodyMedium?.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: BorderRadius.circular(8),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
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/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
|
||||
Future<void> showFeatureMessageSheet(BuildContext context) {
|
||||
return showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
useRootNavigator: true,
|
||||
builder: (_) => const _FeatureMessageSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
class _FeatureMessageSheet extends StatelessWidget {
|
||||
const _FeatureMessageSheet();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BaseBottomSheet(
|
||||
actions: const [],
|
||||
resizeOnScroll: false,
|
||||
expand: false,
|
||||
initialChildSize: 0.6,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.9,
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 4, 24, 16),
|
||||
child: Text(
|
||||
'whats_new'.tr(),
|
||||
style: context.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverList.separated(
|
||||
itemCount: featureMessageHighlights.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||
itemBuilder: (_, index) => _HighlightCard(highlight: featureMessageHighlights[index]),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
],
|
||||
footer: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 16),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 14)),
|
||||
child: Text('feature_message_got_it'.tr()),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HighlightCard extends StatelessWidget {
|
||||
final FeatureHighlight highlight;
|
||||
|
||||
const _HighlightCard({required this.highlight});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: Image.asset(
|
||||
highlight.image,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, _, __) => ColoredBox(
|
||||
color: context.colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.image_outlined, color: context.colorScheme.onSurfaceVariant, size: 48),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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,9 +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)),
|
||||
);
|
||||
|
||||
final featureMessageCheckedProvider = StateProvider<bool>((ref) => false);
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -8,15 +8,13 @@ class SettingsCard extends StatelessWidget {
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
this.settingRoute,
|
||||
this.onTap,
|
||||
required this.settingRoute,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final PageRouteInfo? settingRoute;
|
||||
final VoidCallback? onTap;
|
||||
final PageRouteInfo settingRoute;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -40,7 +38,7 @@ class SettingsCard extends StatelessWidget {
|
||||
),
|
||||
title: Text(title, style: context.textTheme.titleMedium!.copyWith(color: context.primaryColor)),
|
||||
subtitle: Text(subtitle, style: context.textTheme.bodyMedium),
|
||||
onTap: onTap ?? (settingRoute != null ? () => context.pushRoute(settingRoute!) : null),
|
||||
onTap: () => context.pushRoute(settingRoute),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ Immich API
|
||||
|
||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||
|
||||
- API version: 3.0.0-rc.3
|
||||
- API version: 3.0.0-rc.4
|
||||
- Generator version: 7.22.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
|
||||
@@ -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,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 3.0.0-rc.3+3051
|
||||
version: 3.0.0-rc.4+3052
|
||||
|
||||
environment:
|
||||
sdk: '>=3.12.0 <4.0.0'
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
|
||||
Logger.root.level = Level.OFF;
|
||||
EasyLocalization.logger.enableBuildModes = [];
|
||||
// ignore: banned-usage
|
||||
debugPrint = (String? message, {int? wrapWidth}) {};
|
||||
return testMain();
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
@Skip('Flaky test, needs investigation')
|
||||
@Tags(['widget'])
|
||||
library;
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:mocktail/mocktail.dart' as mock;
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
@@ -12,11 +15,11 @@ import 'factories/local_asset_factory.dart';
|
||||
import 'factories/user_factory.dart';
|
||||
|
||||
class RepositoryMocks {
|
||||
final localAlbum = MockLocalAlbumRepository();
|
||||
final localAsset = MockDriftLocalAssetRepository();
|
||||
final localAlbum = LocalAlbumRepositoryStub(MockLocalAlbumRepository());
|
||||
final localAsset = LocalAssetRepositoryStub(MockDriftLocalAssetRepository());
|
||||
final trashedAsset = MockTrashedLocalAssetRepository();
|
||||
|
||||
final nativeApi = MockNativeSyncApi();
|
||||
final nativeApi = NativeSyncApiStub(MockNativeSyncApi());
|
||||
|
||||
RepositoryMocks() {
|
||||
resetAll();
|
||||
@@ -24,17 +27,34 @@ class RepositoryMocks {
|
||||
|
||||
void resetAll() {
|
||||
_registerFallbacks();
|
||||
reset(localAlbum);
|
||||
reset(localAsset);
|
||||
localAlbum.reset();
|
||||
localAsset.reset();
|
||||
reset(trashedAsset);
|
||||
reset(nativeApi);
|
||||
nativeApi.reset();
|
||||
_stubLocalAlbumRepository();
|
||||
_stubLocalAssetRepository();
|
||||
_stubNativeSyncApi();
|
||||
}
|
||||
|
||||
void _stubLocalAlbumRepository() {
|
||||
when(localAlbum.getBackupAlbums).thenAnswer((_) async => []);
|
||||
when(localAlbum.getAssetsToHash).thenAnswer((_) async => []);
|
||||
}
|
||||
|
||||
void _stubLocalAssetRepository() {
|
||||
when(localAsset.reconcileHashesFromCloudId).thenAnswer((_) async => {});
|
||||
when(localAsset.updateHashes).thenAnswer((_) async => {});
|
||||
}
|
||||
|
||||
void _stubNativeSyncApi() {
|
||||
when(nativeApi.hashAssets).thenAnswer((_) async => []);
|
||||
}
|
||||
}
|
||||
|
||||
class ServiceMocks {
|
||||
final PartnerStub partner = PartnerStub(MockPartnerService());
|
||||
final UserStub user = UserStub(MockUserService());
|
||||
final asset = AssetStub(MockAssetService());
|
||||
final partner = PartnerServiceStub(MockPartnerService());
|
||||
final user = UserServiceStub(MockUserService());
|
||||
final asset = AssetServiceStub(MockAssetService());
|
||||
|
||||
ServiceMocks() {
|
||||
resetAll();
|
||||
@@ -78,11 +98,28 @@ void _registerFallbacks() {
|
||||
registerFallbackValue(Uint8List(0));
|
||||
}
|
||||
|
||||
extension type const Stub<T extends Mock>(T mockedService) {
|
||||
void reset() => mock.reset(mockedService);
|
||||
extension type const Stub<T extends Mock>(T mockedClass) {
|
||||
void reset() => mock.reset(mockedClass);
|
||||
}
|
||||
|
||||
extension type const PartnerStub(MockPartnerService service) implements Stub<MockPartnerService> {
|
||||
extension type const LocalAlbumRepositoryStub(MockLocalAlbumRepository repo) implements Stub<MockLocalAlbumRepository> {
|
||||
Future<List<LocalAlbum>> Function() get getBackupAlbums =>
|
||||
() => repo.getBackupAlbums();
|
||||
|
||||
Future<List<LocalAsset>> Function() get getAssetsToHash =>
|
||||
() => repo.getAssetsToHash(any());
|
||||
}
|
||||
|
||||
extension type const LocalAssetRepositoryStub(MockDriftLocalAssetRepository repo)
|
||||
implements Stub<MockDriftLocalAssetRepository> {
|
||||
Future<void> Function() get reconcileHashesFromCloudId =>
|
||||
() => repo.reconcileHashesFromCloudId();
|
||||
|
||||
Future<void> Function() get updateHashes =>
|
||||
() => repo.updateHashes(any());
|
||||
}
|
||||
|
||||
extension type const PartnerServiceStub(MockPartnerService service) implements Stub<MockPartnerService> {
|
||||
Stream<Iterable<User>> Function() get getCandidates =>
|
||||
() => service.getCandidates(any());
|
||||
|
||||
@@ -110,7 +147,7 @@ extension type const PartnerStub(MockPartnerService service) implements Stub<Moc
|
||||
);
|
||||
}
|
||||
|
||||
extension type const UserStub(MockUserService service) implements Stub<MockUserService> {
|
||||
extension type const UserServiceStub(MockUserService service) implements Stub<MockUserService> {
|
||||
UserDto Function() get getMyUser =>
|
||||
() => service.getMyUser();
|
||||
|
||||
@@ -127,7 +164,12 @@ extension type const UserStub(MockUserService service) implements Stub<MockUserS
|
||||
() => service.createProfileImage(any(), any());
|
||||
}
|
||||
|
||||
extension type const AssetStub(MockAssetService service) implements Stub<MockAssetService> {
|
||||
extension type const AssetServiceStub(MockAssetService service) implements Stub<MockAssetService> {
|
||||
Future<void> Function() get updateFavorite =>
|
||||
() => service.updateFavorite(any(), any());
|
||||
}
|
||||
|
||||
extension type const NativeSyncApiStub(MockNativeSyncApi api) implements Stub<MockNativeSyncApi> {
|
||||
Future<List<HashResult>> Function() get hashAssets =>
|
||||
() => api.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'));
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
import '../../factories/remote_asset_factory.dart';
|
||||
import '../../presentation_context.dart';
|
||||
import '../presentation_context.dart';
|
||||
|
||||
void main() {
|
||||
late PresentationContext context;
|
||||
@@ -23,8 +23,8 @@ void main() {
|
||||
group('AssetDebugAction', () {
|
||||
testWidgets('visible for a single asset when advanced troubleshooting is on', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
context,
|
||||
ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])),
|
||||
overrides: context.overrides,
|
||||
);
|
||||
|
||||
expect(find.byType(ImmichIconButton), findsOneWidget);
|
||||
@@ -32,10 +32,10 @@ void main() {
|
||||
|
||||
testWidgets('hidden for multiple assets', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
context,
|
||||
ActionIconButtonWidget(
|
||||
action: AssetDebugAction(assets: [RemoteAssetFactory.create(), RemoteAssetFactory.create()]),
|
||||
),
|
||||
overrides: context.overrides,
|
||||
);
|
||||
|
||||
expect(find.byType(ImmichIconButton), findsNothing);
|
||||
@@ -44,8 +44,8 @@ void main() {
|
||||
testWidgets('hidden when advanced troubleshooting is off', (tester) async {
|
||||
await StoreService.I.put(StoreKey.advancedTroubleshooting, false);
|
||||
await tester.pumpTestWidget(
|
||||
context,
|
||||
ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])),
|
||||
overrides: context.overrides,
|
||||
);
|
||||
|
||||
expect(find.byType(ImmichIconButton), findsNothing);
|
||||
|
||||
@@ -1,30 +1,26 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../../domain/service.mock.dart';
|
||||
import '../../factories/remote_asset_factory.dart';
|
||||
import '../../presentation_context.dart';
|
||||
import '../presentation_context.dart';
|
||||
|
||||
void main() {
|
||||
late PresentationContext context;
|
||||
late MockAssetService assetService;
|
||||
|
||||
setUp(() async {
|
||||
context = await PresentationContext.create();
|
||||
assetService = context.service.asset.service;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
List<Override> overrides() => [
|
||||
...context.overrides,
|
||||
assetServiceProvider.overrideWithValue(context.mocks.asset.service),
|
||||
];
|
||||
|
||||
RemoteAsset owned({bool isFavorite = false}) =>
|
||||
RemoteAssetFactory.create(ownerId: context.currentUser.id, isFavorite: isFavorite);
|
||||
|
||||
@@ -32,48 +28,48 @@ void main() {
|
||||
testWidgets('favorites the eligible owned assets', (tester) async {
|
||||
final asset = owned();
|
||||
|
||||
await tester.pumpTestAction(FavoriteAction(assets: [asset]), overrides: overrides());
|
||||
await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
|
||||
|
||||
verify(() => context.mocks.asset.service.updateFavorite([asset.id], true)).called(1);
|
||||
verify(() => assetService.updateFavorite([asset.id], true)).called(1);
|
||||
});
|
||||
|
||||
testWidgets('unfavorite the eligible owned assets', (tester) async {
|
||||
final asset = owned(isFavorite: true);
|
||||
|
||||
await tester.pumpTestAction(FavoriteAction(assets: [asset]), overrides: overrides());
|
||||
await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
|
||||
|
||||
verify(() => context.mocks.asset.service.updateFavorite([asset.id], false)).called(1);
|
||||
verify(() => assetService.updateFavorite([asset.id], false)).called(1);
|
||||
});
|
||||
|
||||
testWidgets('ignores assets owned by someone else', (tester) async {
|
||||
final mine = owned();
|
||||
final theirs = RemoteAssetFactory.create();
|
||||
|
||||
await tester.pumpTestAction(FavoriteAction(assets: [mine, theirs]), overrides: overrides());
|
||||
await tester.pumpTestAction(context, FavoriteAction(assets: [mine, theirs]));
|
||||
|
||||
verify(() => context.mocks.asset.service.updateFavorite([mine.id], true)).called(1);
|
||||
verify(() => assetService.updateFavorite([mine.id], true)).called(1);
|
||||
});
|
||||
|
||||
testWidgets('batches every eligible owned asset into a single call', (tester) async {
|
||||
final first = owned();
|
||||
final second = owned();
|
||||
|
||||
await tester.pumpTestAction(FavoriteAction(assets: [first, second]), overrides: overrides());
|
||||
await tester.pumpTestAction(context, FavoriteAction(assets: [first, second]));
|
||||
|
||||
verify(() => context.mocks.asset.service.updateFavorite([first.id, second.id], true)).called(1);
|
||||
verify(() => assetService.updateFavorite([first.id, second.id], true)).called(1);
|
||||
});
|
||||
|
||||
testWidgets('skips owned assets already in the target state', (tester) async {
|
||||
final stale = owned();
|
||||
final alreadyFavorite = owned(isFavorite: true);
|
||||
|
||||
await tester.pumpTestAction(FavoriteAction(assets: [stale, alreadyFavorite]), overrides: overrides());
|
||||
await tester.pumpTestAction(context, FavoriteAction(assets: [stale, alreadyFavorite]));
|
||||
|
||||
verify(() => context.mocks.asset.service.updateFavorite([stale.id], true)).called(1);
|
||||
verify(() => assetService.updateFavorite([stale.id], true)).called(1);
|
||||
});
|
||||
|
||||
testWidgets('shows a confirmation snackbar on success', (tester) async {
|
||||
await tester.pumpTestAction(FavoriteAction(assets: [owned()]), overrides: overrides());
|
||||
await tester.pumpTestAction(context, FavoriteAction(assets: [owned()]));
|
||||
await tester.pumpUntilFound(find.byType(SnackBar));
|
||||
|
||||
expect(find.byType(SnackBar), findsOneWidget);
|
||||
|
||||
@@ -4,17 +4,19 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/presentation/actions/partner.action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../../domain/service.mock.dart';
|
||||
import '../../factories/user_factory.dart';
|
||||
import '../../presentation_context.dart';
|
||||
import '../presentation_context.dart';
|
||||
|
||||
void main() {
|
||||
late PresentationContext context;
|
||||
late MockPartnerService partnerService;
|
||||
|
||||
setUp(() async {
|
||||
context = await PresentationContext.create();
|
||||
partnerService = context.service.partner.service;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
@@ -22,8 +24,6 @@ void main() {
|
||||
});
|
||||
|
||||
List<Override> overrides({List<User> candidates = const []}) => [
|
||||
...context.overrides,
|
||||
partnerServiceProvider.overrideWithValue(context.mocks.partner.service),
|
||||
candidatesStateProvider.overrideWith((ref) => Stream<Iterable<User>>.value(candidates)),
|
||||
];
|
||||
|
||||
@@ -31,22 +31,24 @@ void main() {
|
||||
testWidgets('creates a partner for the selected candidate', (tester) async {
|
||||
final candidate = UserFactory.create();
|
||||
|
||||
await tester.pumpTestAction(const PartnerAddAction(), overrides: overrides(candidates: [candidate]));
|
||||
await tester.pumpTestAction(context, const PartnerAddAction(), overrides: overrides(candidates: [candidate]));
|
||||
await tester.pumpUntilFound(find.text(candidate.name));
|
||||
await tester.tap(find.text(candidate.name));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verify(
|
||||
() => context.mocks.partner.service.create(sharedById: context.currentUser.id, sharedWithId: candidate.id),
|
||||
).called(1);
|
||||
verify(() => partnerService.create(sharedById: context.currentUser.id, sharedWithId: candidate.id)).called(1);
|
||||
});
|
||||
|
||||
testWidgets('creates nothing when the selection dialog is dismissed', (tester) async {
|
||||
await tester.pumpTestAction(const PartnerAddAction(), overrides: overrides(candidates: [UserFactory.create()]));
|
||||
await tester.pumpTestAction(
|
||||
context,
|
||||
const PartnerAddAction(),
|
||||
overrides: overrides(candidates: [UserFactory.create()]),
|
||||
);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape); // dismiss without selecting
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verifyNever(context.mocks.partner.create);
|
||||
verifyNever(context.service.partner.create);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,27 +56,27 @@ void main() {
|
||||
testWidgets('deletes the partner after confirmation', (tester) async {
|
||||
final partner = UserFactory.create();
|
||||
await tester.pumpTestAction(
|
||||
context,
|
||||
PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
|
||||
overrides: overrides(),
|
||||
);
|
||||
await tester.tap(find.byType(TextButton).last); // confirm
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verify(
|
||||
() => context.mocks.partner.service.delete(sharedById: context.currentUser.id, sharedWithId: partner.id),
|
||||
).called(1);
|
||||
verify(() => partnerService.delete(sharedById: context.currentUser.id, sharedWithId: partner.id)).called(1);
|
||||
});
|
||||
|
||||
testWidgets('deletes nothing when the confirmation is cancelled', (tester) async {
|
||||
final partner = UserFactory.create();
|
||||
await tester.pumpTestAction(
|
||||
context,
|
||||
PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
|
||||
overrides: overrides(),
|
||||
);
|
||||
await tester.tap(find.byType(TextButton).first); // cancel
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verifyNever(context.mocks.partner.delete);
|
||||
verifyNever(context.service.partner.delete);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
|
||||
import '../../factories/remote_asset_factory.dart';
|
||||
import '../../presentation_context.dart';
|
||||
import '../presentation_context.dart';
|
||||
|
||||
class _FakeAction extends BaseAction {
|
||||
_FakeAction({this.visible = true, this.error});
|
||||
@@ -48,8 +48,7 @@ void main() {
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
List<Override> seededOverrides() => [
|
||||
...context.overrides,
|
||||
List<Override> overrides() => [
|
||||
multiSelectProvider.overrideWith(
|
||||
() => MultiSelectNotifier(
|
||||
MultiSelectState(selectedAssets: {RemoteAssetFactory.create()}, lockedSelectionAssets: const {}),
|
||||
@@ -61,6 +60,7 @@ void main() {
|
||||
late ActionScope scope;
|
||||
late ProviderContainer container;
|
||||
await tester.pumpTestWidget(
|
||||
context,
|
||||
Consumer(
|
||||
builder: (innerContext, ref, _) {
|
||||
scope = ActionScope(context: innerContext, ref: ref, authUser: context.currentUser);
|
||||
@@ -68,7 +68,7 @@ void main() {
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
overrides: seededOverrides(),
|
||||
overrides: overrides(),
|
||||
);
|
||||
return (scope, container);
|
||||
}
|
||||
@@ -97,8 +97,8 @@ void main() {
|
||||
|
||||
testWidgets('delegates visibility to the wrapped action', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
context,
|
||||
ActionIconButtonWidget(action: TimelineAction(action: _FakeAction(visible: false))),
|
||||
overrides: context.overrides,
|
||||
);
|
||||
|
||||
expect(find.byType(ActionIconButtonWidget), findsOneWidget);
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:immich_mobile/presentation/actions/partner.action.dart';
|
||||
|
||||
import '../factories/partner_user_factory.dart';
|
||||
import '../factories/user_factory.dart';
|
||||
import '../presentation_context.dart';
|
||||
import 'presentation_context.dart';
|
||||
|
||||
void main() {
|
||||
late PresentationContext context;
|
||||
@@ -19,7 +19,7 @@ void main() {
|
||||
testWidgets('shows the empty-state add button when there are no partners', (tester) async {
|
||||
final action = const PartnerAddAction();
|
||||
|
||||
await tester.pumpTestWidget(const PartnerSharedByList(partners: []), overrides: context.overrides);
|
||||
await tester.pumpTestWidget(context, const PartnerSharedByList(partners: []));
|
||||
|
||||
expect(find.byType(ListView), findsNothing);
|
||||
expect(find.widgetWithIcon(TextButton, action.icon), findsOneWidget);
|
||||
@@ -28,8 +28,7 @@ void main() {
|
||||
testWidgets('renders a tile per partner with name and email', (tester) async {
|
||||
final partner1 = PartnerFactory.create();
|
||||
final partner2 = PartnerFactory.create();
|
||||
await tester.pumpTestWidget(PartnerSharedByList(partners: [partner1, partner2]), overrides: context.overrides);
|
||||
|
||||
await tester.pumpTestWidget(context, PartnerSharedByList(partners: [partner1, partner2]));
|
||||
expect(find.byType(ListTile), findsNWidgets(2));
|
||||
expect(find.text(partner1.name), findsOneWidget);
|
||||
expect(find.text(partner1.email), findsOneWidget);
|
||||
@@ -41,7 +40,7 @@ void main() {
|
||||
final partner1 = PartnerFactory.create(inTimeline: true);
|
||||
final partner2 = PartnerFactory.create();
|
||||
final action = const PartnerRemoveAction(sharedWithId: '', partnerName: '');
|
||||
await tester.pumpTestWidget(PartnerSharedByList(partners: [partner1, partner2]), overrides: context.overrides);
|
||||
await tester.pumpTestWidget(context, PartnerSharedByList(partners: [partner1, partner2]));
|
||||
expect(find.byIcon(action.icon), findsNWidgets(2));
|
||||
});
|
||||
});
|
||||
@@ -62,13 +61,12 @@ void main() {
|
||||
}
|
||||
|
||||
List<Override> withCandidates(List<User> candidates) => [
|
||||
...context.overrides,
|
||||
candidatesStateProvider.overrideWith((ref) => Stream<Iterable<User>>.value(candidates)),
|
||||
];
|
||||
|
||||
testWidgets('renders an option per candidate fetched from the provider', (tester) async {
|
||||
final user = UserFactory.create();
|
||||
await tester.pumpTestWidget(dialogWidget(), overrides: withCandidates([user]));
|
||||
await tester.pumpTestWidget(context, dialogWidget(), overrides: withCandidates([user]));
|
||||
|
||||
await tester.tap(find.byKey(dialogButtonKey));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -78,7 +76,7 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('shows no options when the provider returns no candidates', (tester) async {
|
||||
await tester.pumpTestWidget(dialogWidget(), overrides: withCandidates(const []));
|
||||
await tester.pumpTestWidget(context, dialogWidget(), overrides: withCandidates(const []));
|
||||
|
||||
await tester.tap(find.byKey(dialogButtonKey));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -89,7 +87,11 @@ void main() {
|
||||
testWidgets('pops the selected candidate when an option is tapped', (tester) async {
|
||||
final user = UserFactory.create();
|
||||
User? selected;
|
||||
await tester.pumpTestWidget(dialogWidget(onClosed: (user) => selected = user), overrides: withCandidates([user]));
|
||||
await tester.pumpTestWidget(
|
||||
context,
|
||||
dialogWidget(onClosed: (user) => selected = user),
|
||||
overrides: withCandidates([user]),
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(dialogButtonKey));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -13,16 +13,21 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../test_utils.dart';
|
||||
import 'factories/user_factory.dart';
|
||||
import 'mocks.dart';
|
||||
import '../../test_utils.dart';
|
||||
import '../factories/user_factory.dart';
|
||||
import '../mocks.dart';
|
||||
|
||||
class PresentationContext {
|
||||
PresentationContext._({required UserDto user}) : currentUser = user, mocks = ServiceMocks() {
|
||||
PresentationContext._({required UserDto user})
|
||||
: currentUser = user,
|
||||
service = ServiceMocks(),
|
||||
repository = RepositoryMocks() {
|
||||
setup();
|
||||
}
|
||||
|
||||
@@ -31,9 +36,14 @@ class PresentationContext {
|
||||
static Drift? _db;
|
||||
|
||||
final UserDto currentUser;
|
||||
final ServiceMocks mocks;
|
||||
final ServiceMocks service;
|
||||
final RepositoryMocks repository;
|
||||
|
||||
List<Override> get overrides => [currentUserProvider.overrideWith((ref) => CurrentUserProvider(mocks.user.service))];
|
||||
List<Override> get overrides => [
|
||||
currentUserProvider.overrideWith((ref) => CurrentUserProvider(service.user.service)),
|
||||
assetServiceProvider.overrideWithValue(service.asset.service),
|
||||
partnerServiceProvider.overrideWithValue(service.partner.service),
|
||||
];
|
||||
|
||||
static Future<PresentationContext> create() async {
|
||||
TestUtils.init();
|
||||
@@ -47,18 +57,18 @@ class PresentationContext {
|
||||
}
|
||||
|
||||
void setup() {
|
||||
when(mocks.user.tryGetMyUser).thenReturn(currentUser);
|
||||
when(service.user.tryGetMyUser).thenReturn(currentUser);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
addTearDown(() {
|
||||
mocks.resetAll();
|
||||
service.resetAll();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension PumpPresentationWidget on WidgetTester {
|
||||
Future<void> pumpTestWidget(Widget widget, {List<Override> overrides = const []}) async {
|
||||
Future<void> pumpTestWidget(PresentationContext context, Widget widget, {List<Override> overrides = const []}) async {
|
||||
await pumpWidget(
|
||||
EasyLocalization(
|
||||
supportedLocales: locales.values.toList(),
|
||||
@@ -69,7 +79,7 @@ extension PumpPresentationWidget on WidgetTester {
|
||||
useFallbackTranslations: true,
|
||||
assetLoader: const CodegenLoader(),
|
||||
child: ProviderScope(
|
||||
overrides: overrides,
|
||||
overrides: [...context.overrides, ...overrides],
|
||||
child: Builder(
|
||||
builder: (context) => MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
@@ -86,8 +96,12 @@ extension PumpPresentationWidget on WidgetTester {
|
||||
await pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> pumpTestAction(BaseAction action, {List<Override> overrides = const []}) async {
|
||||
await pumpTestWidget(ActionIconButtonWidget(action: action), overrides: overrides);
|
||||
Future<void> pumpTestAction(
|
||||
PresentationContext context,
|
||||
BaseAction action, {
|
||||
List<Override> overrides = const [],
|
||||
}) async {
|
||||
await pumpTestWidget(context, ActionIconButtonWidget(action: action), overrides: overrides);
|
||||
await tap(find.byType(ImmichIconButton));
|
||||
await pump();
|
||||
}
|
||||
@@ -14,14 +14,11 @@ void main() {
|
||||
|
||||
setUp(() {
|
||||
sut = HashService(
|
||||
localAlbumRepository: mocks.localAlbum,
|
||||
localAssetRepository: mocks.localAsset,
|
||||
nativeSyncApi: mocks.nativeApi,
|
||||
localAlbumRepository: mocks.localAlbum.repo,
|
||||
localAssetRepository: mocks.localAsset.repo,
|
||||
nativeSyncApi: mocks.nativeApi.api,
|
||||
trashedLocalAssetRepository: mocks.trashedAsset,
|
||||
);
|
||||
|
||||
when(() => mocks.localAsset.reconcileHashesFromCloudId()).thenAnswer((_) async => {});
|
||||
when(() => mocks.localAsset.updateHashes(any())).thenAnswer((_) async => {});
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
@@ -32,22 +29,20 @@ void main() {
|
||||
group('hashAssets', () {
|
||||
test('skips albums with no assets to hash', () async {
|
||||
final album = LocalAlbumFactory.create(assetCount: 0);
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => []);
|
||||
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
verifyNever(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
||||
verifyNever(mocks.nativeApi.hashAssets);
|
||||
});
|
||||
|
||||
test('skips empty batches', () async {
|
||||
final album = LocalAlbumFactory.create();
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => []);
|
||||
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
verifyNever(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
||||
verifyNever(mocks.nativeApi.hashAssets);
|
||||
});
|
||||
|
||||
test('processes assets when available', () async {
|
||||
@@ -55,15 +50,17 @@ void main() {
|
||||
final asset = LocalAssetFactory.create();
|
||||
final result = HashResult(assetId: asset.id, hash: 'test-hash');
|
||||
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||
when(() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false)).thenAnswer((_) async => [result]);
|
||||
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||
when(
|
||||
() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false),
|
||||
).thenAnswer((_) async => [result]);
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
verify(() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false)).called(1);
|
||||
verify(() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false)).called(1);
|
||||
final captured =
|
||||
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||
expect(captured.length, 1);
|
||||
expect(captured[asset.id], result.hash);
|
||||
});
|
||||
@@ -72,16 +69,16 @@ void main() {
|
||||
final album = LocalAlbumFactory.create();
|
||||
final asset = LocalAssetFactory.create();
|
||||
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||
when(
|
||||
() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false),
|
||||
() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false),
|
||||
).thenAnswer((_) async => [HashResult(assetId: asset.id, error: 'Failed to hash')]);
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
final captured =
|
||||
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||
expect(captured.length, 0);
|
||||
});
|
||||
|
||||
@@ -89,25 +86,25 @@ void main() {
|
||||
final album = LocalAlbumFactory.create();
|
||||
final asset = LocalAssetFactory.create();
|
||||
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||
when(
|
||||
() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false),
|
||||
() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false),
|
||||
).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: null)]);
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
final captured =
|
||||
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||
expect(captured.length, 0);
|
||||
});
|
||||
|
||||
test('batches by size limit', () async {
|
||||
const batchSize = 2;
|
||||
final sut = HashService(
|
||||
localAlbumRepository: mocks.localAlbum,
|
||||
localAssetRepository: mocks.localAsset,
|
||||
nativeSyncApi: mocks.nativeApi,
|
||||
localAlbumRepository: mocks.localAlbum.repo,
|
||||
localAssetRepository: mocks.localAsset.repo,
|
||||
nativeSyncApi: mocks.nativeApi.api,
|
||||
batchSize: batchSize,
|
||||
trashedLocalAssetRepository: mocks.trashedAsset,
|
||||
);
|
||||
@@ -119,12 +116,9 @@ void main() {
|
||||
|
||||
final capturedCalls = <List<String>>[];
|
||||
|
||||
when(() => mocks.localAsset.updateHashes(any())).thenAnswer((_) async => {});
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2, asset3]);
|
||||
when(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))).thenAnswer((
|
||||
invocation,
|
||||
) async {
|
||||
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2, asset3]);
|
||||
when(mocks.nativeApi.hashAssets).thenAnswer((invocation) async {
|
||||
final assetIds = invocation.positionalArguments[0] as List<String>;
|
||||
capturedCalls.add(List<String>.from(assetIds));
|
||||
return assetIds.map((id) => HashResult(assetId: id, hash: '$id-hash')).toList();
|
||||
@@ -136,7 +130,7 @@ void main() {
|
||||
expect(capturedCalls[0], [asset1.id, asset2.id], reason: 'First call should batch the first two assets');
|
||||
expect(capturedCalls[1], [asset3.id], reason: 'Second call should have the remaining asset');
|
||||
|
||||
verify(() => mocks.localAsset.updateHashes(any())).called(2);
|
||||
verify(() => mocks.localAsset.repo.updateHashes(any())).called(2);
|
||||
});
|
||||
|
||||
test('handles mixed success and failure in batch', () async {
|
||||
@@ -144,9 +138,9 @@ void main() {
|
||||
final asset1 = LocalAssetFactory.create();
|
||||
final asset2 = LocalAssetFactory.create();
|
||||
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2]);
|
||||
when(() => mocks.nativeApi.hashAssets([asset1.id, asset2.id], allowNetworkAccess: false)).thenAnswer(
|
||||
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2]);
|
||||
when(() => mocks.nativeApi.api.hashAssets([asset1.id, asset2.id], allowNetworkAccess: false)).thenAnswer(
|
||||
(_) async => [
|
||||
HashResult(assetId: asset1.id, hash: 'asset1-hash'),
|
||||
HashResult(assetId: asset2.id, error: 'Failed to hash asset2'),
|
||||
@@ -156,7 +150,7 @@ void main() {
|
||||
await sut.hashAssets();
|
||||
|
||||
final captured =
|
||||
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||
expect(captured.length, 1);
|
||||
expect(captured[asset1.id], 'asset1-hash');
|
||||
});
|
||||
@@ -167,20 +161,18 @@ void main() {
|
||||
final asset1 = LocalAssetFactory.create();
|
||||
final asset2 = LocalAssetFactory.create();
|
||||
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [selectedAlbum, nonSelectedAlbum]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(selectedAlbum.id)).thenAnswer((_) async => [asset1]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(nonSelectedAlbum.id)).thenAnswer((_) async => [asset2]);
|
||||
when(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))).thenAnswer((
|
||||
invocation,
|
||||
) async {
|
||||
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [selectedAlbum, nonSelectedAlbum]);
|
||||
when(() => mocks.localAlbum.repo.getAssetsToHash(selectedAlbum.id)).thenAnswer((_) async => [asset1]);
|
||||
when(() => mocks.localAlbum.repo.getAssetsToHash(nonSelectedAlbum.id)).thenAnswer((_) async => [asset2]);
|
||||
when(mocks.nativeApi.hashAssets).thenAnswer((invocation) async {
|
||||
final assetIds = invocation.positionalArguments[0] as List<String>;
|
||||
return assetIds.map((id) => HashResult(assetId: id, hash: '$id-hash')).toList();
|
||||
});
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
verify(() => mocks.nativeApi.hashAssets([asset1.id], allowNetworkAccess: true)).called(1);
|
||||
verify(() => mocks.nativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
|
||||
verify(() => mocks.nativeApi.api.hashAssets([asset1.id], allowNetworkAccess: true)).called(1);
|
||||
verify(() => mocks.nativeApi.api.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,27 +1,51 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/locales.dart';
|
||||
import 'package:immich_mobile/generated/codegen_loader.g.dart';
|
||||
|
||||
extension PumpConsumerWidget on WidgetTester {
|
||||
/// Wraps the provided [widget] with Material app such that it becomes:
|
||||
/// Wraps the provided [widget] with a localized Material app such that it
|
||||
/// becomes:
|
||||
///
|
||||
/// EasyLocalization
|
||||
/// |-ProviderScope
|
||||
/// |-MaterialApp (localization delegates wired up)
|
||||
/// |-Material
|
||||
/// |-[widget]
|
||||
///
|
||||
/// ProviderScope
|
||||
/// |-MaterialApp
|
||||
/// |-Material
|
||||
/// |-[widget]
|
||||
Future<void> pumpConsumerWidget(
|
||||
Widget widget, {
|
||||
Duration? duration,
|
||||
EnginePhase phase = EnginePhase.sendSemanticsUpdate,
|
||||
List<Override> overrides = const [],
|
||||
}) async {
|
||||
return pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: overrides,
|
||||
child: MaterialApp(debugShowCheckedModeBanner: false, home: Material(child: widget)),
|
||||
await pumpWidget(
|
||||
EasyLocalization(
|
||||
supportedLocales: locales.values.toList(),
|
||||
path: translationsPath,
|
||||
startLocale: locales.values.first,
|
||||
fallbackLocale: locales.values.first,
|
||||
saveLocale: false,
|
||||
useFallbackTranslations: true,
|
||||
assetLoader: const CodegenLoader(),
|
||||
child: ProviderScope(
|
||||
overrides: overrides,
|
||||
child: Builder(
|
||||
builder: (context) => MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
localizationsDelegates: context.localizationDelegates,
|
||||
supportedLocales: context.supportedLocales,
|
||||
locale: context.locale,
|
||||
home: Material(child: widget),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
duration: duration,
|
||||
phase: phase,
|
||||
);
|
||||
await pumpAndSettle();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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');
|
||||
@@ -16206,7 +16206,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "3.0.0-rc.3",
|
||||
"version": "3.0.0-rc.4",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-monorepo",
|
||||
"version": "3.0.0-rc.3",
|
||||
"version": "3.0.0-rc.4",
|
||||
"description": "Monorepo for Immich",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "3.0.0-rc.3",
|
||||
"version": "3.0.0-rc.4",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "3.0.0-rc.3",
|
||||
"version": "3.0.0-rc.4",
|
||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Immich
|
||||
* 3.0.0-rc.3
|
||||
* 3.0.0-rc.4
|
||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||
* See https://www.npmjs.com/package/oazapfts
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "3.0.0-rc.3",
|
||||
"version": "3.0.0-rc.4",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "3.0.0-rc.3",
|
||||
"version": "3.0.0-rc.4",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||