Compare commits

..

1 Commits

Author SHA1 Message Date
Santo Shakil b4a4ddfd56 fix(mobile): keep drag select going while the grid auto scrolls 2026-06-28 00:46:24 +06:00
57 changed files with 721 additions and 2311 deletions
-1
View File
@@ -5,4 +5,3 @@
/machine-learning/ @mertalev
/e2e/ @danieldietzler
/mobile/ @shenlong-tanwen @santoshakil
/native/ @santoshakil @mertalev
@@ -1,33 +0,0 @@
// 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]);
});
}
@@ -182,6 +182,18 @@ class TimelineService {
return _buffer.slice(start, start + count);
}
/// Reads a range without disturbing the buffer; queries the source if it isn't resident.
Future<List<BaseAsset>> getAssetsRange(int index, int count) async {
if (index < 0 || count <= 0 || index >= _totalAssets) {
return const [];
}
final clamped = math.min(count, _totalAssets - index);
if (hasRange(index, clamped)) {
return getAssets(index, clamped);
}
return _assetSource(index, clamped);
}
// Preload assets around the given index for asset viewer
Future<void> preloadAssets(int index) => _mutex.run(() => _loadAssets(index, math.min(5, _totalAssets - index)));
@@ -0,0 +1,175 @@
import 'dart:collection';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
// Tracks the [anchor..current] range selected by a drag. The in-buffer part of
// each tick is selected synchronously so it follows the finger without racing;
// the rare beyond-buffer part is read async (applied only if still in range) and
// [end] reads whatever is still missing so the final range always lands.
class DragSelectionController {
DragSelectionController({required this.getAssetSafe, required this.getAssetsRange, required this.onChange});
final BaseAsset? Function(int index) getAssetSafe;
final Future<List<BaseAsset>> Function(int index, int count) getAssetsRange;
final void Function(Set<BaseAsset> select, Set<BaseAsset> deselect) onChange;
final HashMap<int, BaseAsset> _selected = HashMap();
// Indices the buffer didn't hold yet (the edge outran the async buffer-load on a
// fast scroll); read in by _extendPending and guaranteed by end().
final Set<int> _pending = {};
int? _anchor;
int? _lo;
int? _hi;
bool _disposed = false;
// Call before starting a new drag so a previous drag's in-flight read can't
// leak into the new selection.
void dispose() => _disposed = true;
void _emit(Set<BaseAsset> select, Set<BaseAsset> deselect) {
if (_disposed) {
return;
}
onChange(select, deselect);
}
void start(int anchor) {
_selected.clear();
_pending.clear();
_anchor = anchor;
_lo = anchor;
_hi = anchor;
_select(anchor);
_extendPending();
}
void enter(int current) {
final anchor = _anchor;
if (anchor == null || _lo == null || _hi == null) {
return;
}
final ns = current < anchor ? current : anchor;
final ne = current < anchor ? anchor : current;
final ps = _lo!;
final pe = _hi!;
if (ns == ps && ne == pe) {
return;
}
final toSelect = <BaseAsset>{};
final toDeselect = <BaseAsset>{};
_forEach(ps, ns - 1, (k) => _removeIndex(k, toDeselect));
_forEach(ne + 1, pe, (k) => _removeIndex(k, toDeselect));
_forEach(ns, ps - 1, (k) => _addIndex(k, toSelect));
_forEach(pe + 1, ne, (k) => _addIndex(k, toSelect));
_lo = ns;
_hi = ne;
if (toSelect.isNotEmpty || toDeselect.isNotEmpty) {
_emit(toSelect, toDeselect);
}
_extendPending();
}
Future<void> end() async {
final lo = _lo;
final hi = _hi;
if (lo == null || hi == null) {
return;
}
final missing = <int>[];
for (var k = lo; k <= hi; k++) {
if (!_selected.containsKey(k)) {
missing.add(k);
}
}
if (missing.isEmpty) {
return;
}
final from = missing.first;
final assets = await getAssetsRange(from, missing.last - from + 1);
final missingSet = missing.toSet();
final toSelect = <BaseAsset>{};
for (var i = 0; i < assets.length; i++) {
final idx = from + i;
if (missingSet.contains(idx) && !_selected.containsKey(idx)) {
_selected[idx] = assets[i];
_pending.remove(idx);
toSelect.add(assets[i]);
}
}
if (toSelect.isNotEmpty) {
_emit(toSelect, const {});
}
}
void _select(int index) {
final asset = getAssetSafe(index);
if (asset != null) {
_selected[index] = asset;
_pending.remove(index);
_emit({asset}, const {});
} else {
_pending.add(index);
}
}
void _addIndex(int index, Set<BaseAsset> toSelect) {
if (_selected.containsKey(index)) {
return;
}
final asset = getAssetSafe(index);
if (asset != null) {
_selected[index] = asset;
_pending.remove(index);
toSelect.add(asset);
} else {
_pending.add(index);
}
}
void _removeIndex(int index, Set<BaseAsset> toDeselect) {
_pending.remove(index);
final asset = _selected.remove(index);
if (asset != null) {
toDeselect.add(asset);
}
}
Future<void> _extendPending() async {
if (_pending.isEmpty) {
return;
}
var from = _pending.first;
var to = _pending.first;
for (final k in _pending) {
if (k < from) {
from = k;
}
if (k > to) {
to = k;
}
}
final assets = await getAssetsRange(from, to - from + 1);
final toSelect = <BaseAsset>{};
for (var i = 0; i < assets.length; i++) {
final idx = from + i;
if (_pending.contains(idx) && _lo != null && idx >= _lo! && idx <= _hi!) {
_selected[idx] = assets[i];
toSelect.add(assets[i]);
}
}
_pending.removeWhere((idx) => _selected.containsKey(idx));
if (toSelect.isNotEmpty) {
_emit(toSelect, const {});
}
}
void _forEach(int lo, int hi, void Function(int) fn) {
for (var k = lo; k <= hi; k++) {
fn(k);
}
}
}
@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:collection';
import 'dart:math' as math;
import 'package:collection/collection.dart';
@@ -8,7 +7,6 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
@@ -20,6 +18,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
import 'package:immich_mobile/presentation/widgets/timeline/drag_selection_controller.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
@@ -29,6 +28,27 @@ import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
// First asset index of the row shown at [offset]. Pure for testing.
@visibleForTesting
int? assetIndexAtOffset(
List<Segment> segments,
double offset, {
required int columnCount,
required double maxScrollExtent,
}) {
final clamped = offset.clamp(0.0, maxScrollExtent);
final segment = segments.findByOffset(clamped) ?? segments.lastOrNull;
if (segment == null) {
return null;
}
final rowIndex = segment.getMinChildIndexForScrollOffset(clamped);
if (rowIndex > segment.firstIndex) {
final rowIndexInSegment = rowIndex - (segment.firstIndex + 1);
return segment.firstAssetIndex + rowIndexInSegment * columnCount;
}
return segment.firstAssetIndex;
}
class Timeline extends StatelessWidget {
const Timeline({
super.key,
@@ -140,9 +160,10 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
StreamSubscription? _eventSubscription;
// Drag selection state
static const _autoScrollStep = 175.0;
static const _autoScrollDuration = Duration(milliseconds: 125);
bool _dragging = false;
TimelineAssetIndex? _dragAnchorIndex;
final Set<BaseAsset> _draggedAssets = HashSet();
DragSelectionController? _dragController;
ScrollPhysics? _scrollPhysics;
int _perRow = 4;
@@ -226,26 +247,18 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
EventStream.shared.emit(MultiSelectToggleEvent(isEnabled));
}
int? _getCurrentAssetIndex(List<Segment> segments) {
final currentOffset = _scrollController.offset.clamp(0.0, _scrollController.position.maxScrollExtent);
final segment = segments.findByOffset(currentOffset) ?? segments.lastOrNull;
int? targetAssetIndex;
if (segment != null) {
final rowIndex = segment.getMinChildIndexForScrollOffset(currentOffset);
if (rowIndex > segment.firstIndex) {
final rowIndexInSegment = rowIndex - (segment.firstIndex + 1);
final assetsPerRow = ref.read(timelineArgsProvider).columnCount;
final assetIndexInSegment = rowIndexInSegment * assetsPerRow;
targetAssetIndex = segment.firstAssetIndex + assetIndexInSegment;
} else {
targetAssetIndex = segment.firstAssetIndex;
}
}
return targetAssetIndex;
}
int? _getCurrentAssetIndex(List<Segment> segments) => _assetIndexAtOffset(segments, _scrollController.offset);
int? _assetIndexAtOffset(List<Segment> segments, double offset) => assetIndexAtOffset(
segments,
offset,
columnCount: ref.read(timelineArgsProvider).columnCount,
maxScrollExtent: _scrollController.position.maxScrollExtent,
);
@override
void dispose() {
_dragController?.dispose();
_scrollController.dispose();
_eventSubscription?.cancel();
super.dispose();
@@ -295,9 +308,21 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
// Drag selection methods
void _setDragStartIndex(TimelineAssetIndex index) {
// Stop the old drag's controller so its in-flight read can't leak into this one.
_dragController?.dispose();
final timelineService = ref.read(timelineServiceProvider);
_dragController = DragSelectionController(
getAssetSafe: timelineService.getAssetSafe,
getAssetsRange: timelineService.getAssetsRange,
onChange: (select, deselect) {
if (!mounted) {
return;
}
ref.read(multiSelectProvider.notifier).selectRange(select, deselect);
},
)..start(index.assetIndex);
setState(() {
_scrollPhysics = const ClampingScrollPhysics();
_dragAnchorIndex = index;
_dragging = true;
});
}
@@ -313,8 +338,12 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
});
setState(() {
_dragging = false;
_draggedAssets.clear();
});
// Apply the full final range even if a read is still in flight on lift.
final finishing = _dragController?.end();
if (finishing != null) {
unawaited(finishing);
}
final timelineState = ref.read(timelineStateProvider.notifier);
Future.delayed(const Duration(milliseconds: 300), () {
timelineState.setScrolling(false);
@@ -322,42 +351,33 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
}
void _dragScroll(ScrollDirection direction) {
_scrollController.animateTo(
_scrollController.offset + (direction == ScrollDirection.forward ? 175 : -175),
duration: const Duration(milliseconds: 125),
curve: Curves.easeOut,
);
final position = _scrollController.position;
final step = direction == ScrollDirection.forward ? _autoScrollStep : -_autoScrollStep;
final target = (_scrollController.offset + step).clamp(0.0, position.maxScrollExtent);
_scrollController.animateTo(target, duration: _autoScrollDuration, curve: Curves.easeOut);
// A held finger emits no move events, so extend the selection to the asset
// at the leading edge of the scroll instead.
final controller = _dragController;
if (controller == null) {
return;
}
final segments = ref.read(timelineSegmentProvider).valueOrNull;
if (segments == null) {
return;
}
final edgeOffset = direction == ScrollDirection.forward ? target + position.viewportDimension : target;
final edgeIndex = _assetIndexAtOffset(segments, edgeOffset);
if (edgeIndex != null) {
controller.enter(edgeIndex);
}
}
void _handleDragAssetEnter(TimelineAssetIndex index) {
if (_dragAnchorIndex == null || !_dragging) {
if (!_dragging) {
return;
}
final timelineService = ref.read(timelineServiceProvider);
final dragAnchorIndex = _dragAnchorIndex!;
// Calculate the range of assets to select
final startIndex = math.min(dragAnchorIndex.assetIndex, index.assetIndex);
final endIndex = math.max(dragAnchorIndex.assetIndex, index.assetIndex);
final count = endIndex - startIndex + 1;
// Load the assets in the range
if (timelineService.hasRange(startIndex, count)) {
final selectedAssets = timelineService.getAssets(startIndex, count);
// Clear previous drag selection and add new range
final multiSelectNotifier = ref.read(multiSelectProvider.notifier);
for (final asset in _draggedAssets) {
multiSelectNotifier.deselectAsset(asset);
}
_draggedAssets.clear();
for (final asset in selectedAssets) {
multiSelectNotifier.selectAsset(asset);
_draggedAssets.add(asset);
}
}
_dragController?.enter(index.assetIndex);
}
@override
@@ -97,6 +97,15 @@ class MultiSelectNotifier extends Notifier<MultiSelectState> {
}
}
// Drops the previous drag range and adds the new one in a single update. The
// full-set copy per drag tick is the accepted cost of immutable state.
void selectRange(Set<BaseAsset> toSelect, Set<BaseAsset> toDeselect) {
final selectedAssets = state.selectedAssets.toSet()
..removeAll(toDeselect)
..addAll(toSelect);
state = state.copyWith(selectedAssets: selectedAssets);
}
void reset() {
state = const MultiSelectState(selectedAssets: {}, lockedSelectionAssets: {}, forceEnable: false);
}
-23
View File
@@ -912,13 +912,6 @@ 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:
@@ -1131,14 +1124,6 @@ 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:
@@ -1770,14 +1755,6 @@ 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
View File
@@ -39,8 +39,6 @@ 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
-13
View File
@@ -1,13 +0,0 @@
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,4 +1,5 @@
@Skip('Flaky test, needs investigation')
@Tags(['widget'])
library;
import 'package:drift/drift.dart';
+14 -56
View File
@@ -1,10 +1,7 @@
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';
@@ -15,11 +12,11 @@ import 'factories/local_asset_factory.dart';
import 'factories/user_factory.dart';
class RepositoryMocks {
final localAlbum = LocalAlbumRepositoryStub(MockLocalAlbumRepository());
final localAsset = LocalAssetRepositoryStub(MockDriftLocalAssetRepository());
final localAlbum = MockLocalAlbumRepository();
final localAsset = MockDriftLocalAssetRepository();
final trashedAsset = MockTrashedLocalAssetRepository();
final nativeApi = NativeSyncApiStub(MockNativeSyncApi());
final nativeApi = MockNativeSyncApi();
RepositoryMocks() {
resetAll();
@@ -27,34 +24,17 @@ class RepositoryMocks {
void resetAll() {
_registerFallbacks();
localAlbum.reset();
localAsset.reset();
reset(localAlbum);
reset(localAsset);
reset(trashedAsset);
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 => []);
reset(nativeApi);
}
}
class ServiceMocks {
final partner = PartnerServiceStub(MockPartnerService());
final user = UserServiceStub(MockUserService());
final asset = AssetServiceStub(MockAssetService());
final PartnerStub partner = PartnerStub(MockPartnerService());
final UserStub user = UserStub(MockUserService());
final asset = AssetStub(MockAssetService());
ServiceMocks() {
resetAll();
@@ -98,28 +78,11 @@ void _registerFallbacks() {
registerFallbackValue(Uint8List(0));
}
extension type const Stub<T extends Mock>(T mockedClass) {
void reset() => mock.reset(mockedClass);
extension type const Stub<T extends Mock>(T mockedService) {
void reset() => mock.reset(mockedService);
}
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> {
extension type const PartnerStub(MockPartnerService service) implements Stub<MockPartnerService> {
Stream<Iterable<User>> Function() get getCandidates =>
() => service.getCandidates(any());
@@ -147,7 +110,7 @@ extension type const PartnerServiceStub(MockPartnerService service) implements S
);
}
extension type const UserServiceStub(MockUserService service) implements Stub<MockUserService> {
extension type const UserStub(MockUserService service) implements Stub<MockUserService> {
UserDto Function() get getMyUser =>
() => service.getMyUser();
@@ -164,12 +127,7 @@ extension type const UserServiceStub(MockUserService service) implements Stub<Mo
() => service.createProfileImage(any(), any());
}
extension type const AssetServiceStub(MockAssetService service) implements Stub<MockAssetService> {
extension type const AssetStub(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,26 +1,30 @@
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);
@@ -28,48 +32,48 @@ void main() {
testWidgets('favorites the eligible owned assets', (tester) async {
final asset = owned();
await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
await tester.pumpTestAction(FavoriteAction(assets: [asset]), overrides: overrides());
verify(() => assetService.updateFavorite([asset.id], true)).called(1);
verify(() => context.mocks.asset.service.updateFavorite([asset.id], true)).called(1);
});
testWidgets('unfavorite the eligible owned assets', (tester) async {
final asset = owned(isFavorite: true);
await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
await tester.pumpTestAction(FavoriteAction(assets: [asset]), overrides: overrides());
verify(() => assetService.updateFavorite([asset.id], false)).called(1);
verify(() => context.mocks.asset.service.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(context, FavoriteAction(assets: [mine, theirs]));
await tester.pumpTestAction(FavoriteAction(assets: [mine, theirs]), overrides: overrides());
verify(() => assetService.updateFavorite([mine.id], true)).called(1);
verify(() => context.mocks.asset.service.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(context, FavoriteAction(assets: [first, second]));
await tester.pumpTestAction(FavoriteAction(assets: [first, second]), overrides: overrides());
verify(() => assetService.updateFavorite([first.id, second.id], true)).called(1);
verify(() => context.mocks.asset.service.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(context, FavoriteAction(assets: [stale, alreadyFavorite]));
await tester.pumpTestAction(FavoriteAction(assets: [stale, alreadyFavorite]), overrides: overrides());
verify(() => assetService.updateFavorite([stale.id], true)).called(1);
verify(() => context.mocks.asset.service.updateFavorite([stale.id], true)).called(1);
});
testWidgets('shows a confirmation snackbar on success', (tester) async {
await tester.pumpTestAction(context, FavoriteAction(assets: [owned()]));
await tester.pumpTestAction(FavoriteAction(assets: [owned()]), overrides: overrides());
await tester.pumpUntilFound(find.byType(SnackBar));
expect(find.byType(SnackBar), findsOneWidget);
@@ -4,19 +4,17 @@ 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(() {
@@ -24,6 +22,8 @@ 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,24 +31,22 @@ void main() {
testWidgets('creates a partner for the selected candidate', (tester) async {
final candidate = UserFactory.create();
await tester.pumpTestAction(context, const PartnerAddAction(), overrides: overrides(candidates: [candidate]));
await tester.pumpTestAction(const PartnerAddAction(), overrides: overrides(candidates: [candidate]));
await tester.pumpUntilFound(find.text(candidate.name));
await tester.tap(find.text(candidate.name));
await tester.pumpAndSettle();
verify(() => partnerService.create(sharedById: context.currentUser.id, sharedWithId: candidate.id)).called(1);
verify(
() => context.mocks.partner.service.create(sharedById: context.currentUser.id, sharedWithId: candidate.id),
).called(1);
});
testWidgets('creates nothing when the selection dialog is dismissed', (tester) async {
await tester.pumpTestAction(
context,
const PartnerAddAction(),
overrides: overrides(candidates: [UserFactory.create()]),
);
await tester.pumpTestAction(const PartnerAddAction(), overrides: overrides(candidates: [UserFactory.create()]));
await tester.sendKeyEvent(LogicalKeyboardKey.escape); // dismiss without selecting
await tester.pumpAndSettle();
verifyNever(context.service.partner.create);
verifyNever(context.mocks.partner.create);
});
});
@@ -56,27 +54,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(() => partnerService.delete(sharedById: context.currentUser.id, sharedWithId: partner.id)).called(1);
verify(
() => context.mocks.partner.service.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.service.partner.delete);
verifyNever(context.mocks.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,7 +48,8 @@ void main() {
context.dispose();
});
List<Override> overrides() => [
List<Override> seededOverrides() => [
...context.overrides,
multiSelectProvider.overrideWith(
() => MultiSelectNotifier(
MultiSelectState(selectedAssets: {RemoteAssetFactory.create()}, lockedSelectionAssets: const {}),
@@ -60,7 +61,6 @@ 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: overrides(),
overrides: seededOverrides(),
);
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(context, const PartnerSharedByList(partners: []));
await tester.pumpTestWidget(const PartnerSharedByList(partners: []), overrides: context.overrides);
expect(find.byType(ListView), findsNothing);
expect(find.widgetWithIcon(TextButton, action.icon), findsOneWidget);
@@ -28,7 +28,8 @@ 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(context, PartnerSharedByList(partners: [partner1, partner2]));
await tester.pumpTestWidget(PartnerSharedByList(partners: [partner1, partner2]), overrides: context.overrides);
expect(find.byType(ListTile), findsNWidgets(2));
expect(find.text(partner1.name), findsOneWidget);
expect(find.text(partner1.email), findsOneWidget);
@@ -40,7 +41,7 @@ void main() {
final partner1 = PartnerFactory.create(inTimeline: true);
final partner2 = PartnerFactory.create();
final action = const PartnerRemoveAction(sharedWithId: '', partnerName: '');
await tester.pumpTestWidget(context, PartnerSharedByList(partners: [partner1, partner2]));
await tester.pumpTestWidget(PartnerSharedByList(partners: [partner1, partner2]), overrides: context.overrides);
expect(find.byIcon(action.icon), findsNWidgets(2));
});
});
@@ -61,12 +62,13 @@ 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(context, dialogWidget(), overrides: withCandidates([user]));
await tester.pumpTestWidget(dialogWidget(), overrides: withCandidates([user]));
await tester.tap(find.byKey(dialogButtonKey));
await tester.pumpAndSettle();
@@ -76,7 +78,7 @@ void main() {
});
testWidgets('shows no options when the provider returns no candidates', (tester) async {
await tester.pumpTestWidget(context, dialogWidget(), overrides: withCandidates(const []));
await tester.pumpTestWidget(dialogWidget(), overrides: withCandidates(const []));
await tester.tap(find.byKey(dialogButtonKey));
await tester.pumpAndSettle();
@@ -87,11 +89,7 @@ void main() {
testWidgets('pops the selected candidate when an option is tapped', (tester) async {
final user = UserFactory.create();
User? selected;
await tester.pumpTestWidget(
context,
dialogWidget(onClosed: (user) => selected = user),
overrides: withCandidates([user]),
);
await tester.pumpTestWidget(dialogWidget(onClosed: (user) => selected = user), overrides: withCandidates([user]));
await tester.tap(find.byKey(dialogButtonKey));
await tester.pumpAndSettle();
@@ -0,0 +1,60 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
void main() {
// Two day-segments, 4 columns, 8 assets each (2 rows). header 50, tile 100, no spacing.
// A: header@[0,50) rows@50,150 assets 0..7 offset [0,250]
// B: header@[250,300) rows@300,400 assets 8..15 offset [250,500]
const columnCount = 4;
const maxScrollExtent = 500.0;
final segments = <Segment>[
const FixedSegment(
firstIndex: 0,
lastIndex: 2,
startOffset: 0,
endOffset: 250,
firstAssetIndex: 0,
bucket: Bucket(assetCount: 8),
tileHeight: 100,
columnCount: columnCount,
headerExtent: 50,
spacing: 0,
header: HeaderType.day,
),
const FixedSegment(
firstIndex: 3,
lastIndex: 5,
startOffset: 250,
endOffset: 500,
firstAssetIndex: 8,
bucket: Bucket(assetCount: 8),
tileHeight: 100,
columnCount: columnCount,
headerExtent: 50,
spacing: 0,
header: HeaderType.day,
),
];
int? at(double offset) =>
assetIndexAtOffset(segments, offset, columnCount: columnCount, maxScrollExtent: maxScrollExtent);
test('maps an offset to the first asset of the row shown there', () {
expect(at(0), 0); // top of segment A
expect(at(150), 4); // second row of A
expect(at(350), 8); // first row of B
expect(at(450), 12); // second row of B
});
test('clamps offsets outside the scroll range', () {
expect(at(-100), 0); // below the top -> first asset
expect(at(9999), at(maxScrollExtent)); // past the end -> same as the max offset
});
test('returns null for empty segments', () {
expect(assetIndexAtOffset(const [], 100, columnCount: columnCount, maxScrollExtent: maxScrollExtent), isNull);
});
}
@@ -0,0 +1,157 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/drag_selection_controller.dart';
import '../../../factories/remote_asset_factory.dart';
void main() {
const total = 50;
late List<BaseAsset> all;
late Set<int> inBuffer; // indices getAssetSafe resolves synchronously
late List<Completer<List<BaseAsset>>> reads; // pending async reads, completed manually
late List<({int from, int count})> readArgs;
late Set<BaseAsset> selected;
late DragSelectionController sut;
Set<BaseAsset> range(int lo, int hi) => {for (var i = lo; i <= hi; i++) all[i]};
void completeRead(int i) {
final a = readArgs[i];
reads[i].complete([for (var k = a.from; k < a.from + a.count && k < total; k++) all[k]]);
}
Future<void> settle() => Future(() {});
setUp(() {
all = List.generate(total, (i) => RemoteAssetFactory.create(id: 'a${i.toString().padLeft(3, '0')}'));
inBuffer = {for (var i = 0; i < total; i++) i}; // default: everything buffered (sync)
reads = [];
readArgs = [];
selected = {};
sut = DragSelectionController(
getAssetSafe: (i) => inBuffer.contains(i) ? all[i] : null,
getAssetsRange: (from, count) {
final c = Completer<List<BaseAsset>>();
reads.add(c);
readArgs.add((from: from, count: count));
return c.future;
},
onChange: (select, deselect) {
selected
..addAll(select)
..removeAll(deselect);
},
);
});
group('live selection (all in buffer, synchronous)', () {
test('dragging down selects the whole range as it grows', () {
sut.start(2);
sut.enter(6);
sut.enter(12);
expect(selected, range(2, 12));
expect(reads, isEmpty, reason: 'everything buffered -> no async read');
});
test('dragging back up toward the anchor deselects the shrunk tail', () {
sut.start(2);
sut.enter(12);
expect(selected, range(2, 12));
sut.enter(6); // reverse
expect(selected, range(2, 6));
sut.enter(3); // reverse more
expect(selected, range(2, 3));
});
test('dragging past the anchor flips the range', () {
sut.start(10);
sut.enter(14);
expect(selected, range(10, 14));
sut.enter(7); // crosses the anchor
expect(selected, range(7, 10));
});
});
group('beyond-buffer (async)', () {
setUp(() => inBuffer = {}); // nothing buffered -> every tile needs an async read
test('drag-end fills the full range even if every live read is still in flight', () async {
sut.start(0);
sut.enter(20);
// simulate the real-rate race: none of the in-drag reads have completed
expect(selected, isEmpty);
final ending = sut.end();
// end() issues its own read for the missing range; complete it
completeRead(reads.length - 1);
await ending;
expect(selected, range(0, 20), reason: 'final range must always apply on drag-end');
});
test('out-of-order live read completions never corrupt the selection', () async {
sut.start(0); // issues read for [0,1]
sut.enter(10); // issues read for [0,11]
sut.enter(20); // issues read for [0,21]
expect(reads.length, 3);
// complete newest first, then older ones (out of order)
completeRead(2);
await settle();
completeRead(1);
await settle();
completeRead(0);
await settle();
expect(selected, range(0, 20));
final ending = sut.end();
await ending; // nothing missing -> no extra read
expect(selected, range(0, 20));
});
test('a disposed controller never emits when its in-flight read resolves', () async {
sut.start(0); // read [0,1] (pending, in flight)
sut.enter(20); // read [0,21] (pending, in flight)
expect(selected, isEmpty);
// a new drag starts -> the old controller is disposed
final ending = sut.end(); // issues end()'s fill read
sut.dispose();
// every in-flight read for the old controller now resolves
for (var i = 0; i < reads.length; i++) {
if (!reads[i].isCompleted) {
completeRead(i);
}
}
await ending;
await settle();
expect(selected, isEmpty, reason: 'a disposed controller must not leak into the new selection');
});
test('a late read for tiles dragged back out of range is ignored', () async {
sut.start(0); // read [0,1]
sut.enter(20); // read [0,21]
sut.enter(5); // shrink back; read [0,6]
// complete the stale wide read AFTER the shrink
completeRead(1); // [0,21]
await settle();
// indices 6..20 left the range -> must not be selected
expect(selected.intersection(range(6, 20)), isEmpty);
final ending = sut.end();
// complete any read end() issued for the (now smaller) missing range
for (var i = 0; i < reads.length; i++) {
if (!reads[i].isCompleted) {
completeRead(i);
}
}
await ending;
expect(selected, range(0, 5));
});
});
}
@@ -13,21 +13,16 @@ 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,
service = ServiceMocks(),
repository = RepositoryMocks() {
PresentationContext._({required UserDto user}) : currentUser = user, mocks = ServiceMocks() {
setup();
}
@@ -36,14 +31,9 @@ class PresentationContext {
static Drift? _db;
final UserDto currentUser;
final ServiceMocks service;
final RepositoryMocks repository;
final ServiceMocks mocks;
List<Override> get overrides => [
currentUserProvider.overrideWith((ref) => CurrentUserProvider(service.user.service)),
assetServiceProvider.overrideWithValue(service.asset.service),
partnerServiceProvider.overrideWithValue(service.partner.service),
];
List<Override> get overrides => [currentUserProvider.overrideWith((ref) => CurrentUserProvider(mocks.user.service))];
static Future<PresentationContext> create() async {
TestUtils.init();
@@ -57,18 +47,18 @@ class PresentationContext {
}
void setup() {
when(service.user.tryGetMyUser).thenReturn(currentUser);
when(mocks.user.tryGetMyUser).thenReturn(currentUser);
}
void dispose() {
addTearDown(() {
service.resetAll();
mocks.resetAll();
});
}
}
extension PumpPresentationWidget on WidgetTester {
Future<void> pumpTestWidget(PresentationContext context, Widget widget, {List<Override> overrides = const []}) async {
Future<void> pumpTestWidget(Widget widget, {List<Override> overrides = const []}) async {
await pumpWidget(
EasyLocalization(
supportedLocales: locales.values.toList(),
@@ -79,7 +69,7 @@ extension PumpPresentationWidget on WidgetTester {
useFallbackTranslations: true,
assetLoader: const CodegenLoader(),
child: ProviderScope(
overrides: [...context.overrides, ...overrides],
overrides: overrides,
child: Builder(
builder: (context) => MaterialApp(
debugShowCheckedModeBanner: false,
@@ -96,12 +86,8 @@ extension PumpPresentationWidget on WidgetTester {
await pumpAndSettle();
}
Future<void> pumpTestAction(
PresentationContext context,
BaseAction action, {
List<Override> overrides = const [],
}) async {
await pumpTestWidget(context, ActionIconButtonWidget(action: action), overrides: overrides);
Future<void> pumpTestAction(BaseAction action, {List<Override> overrides = const []}) async {
await pumpTestWidget(ActionIconButtonWidget(action: action), overrides: overrides);
await tap(find.byType(ImmichIconButton));
await pump();
}
@@ -14,11 +14,14 @@ void main() {
setUp(() {
sut = HashService(
localAlbumRepository: mocks.localAlbum.repo,
localAssetRepository: mocks.localAsset.repo,
nativeSyncApi: mocks.nativeApi.api,
localAlbumRepository: mocks.localAlbum,
localAssetRepository: mocks.localAsset,
nativeSyncApi: mocks.nativeApi,
trashedLocalAssetRepository: mocks.trashedAsset,
);
when(() => mocks.localAsset.reconcileHashesFromCloudId()).thenAnswer((_) async => {});
when(() => mocks.localAsset.updateHashes(any())).thenAnswer((_) async => {});
});
tearDown(() {
@@ -29,20 +32,22 @@ 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.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => []);
await sut.hashAssets();
verifyNever(mocks.nativeApi.hashAssets);
verifyNever(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('skips empty batches', () async {
final album = LocalAlbumFactory.create();
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => []);
await sut.hashAssets();
verifyNever(mocks.nativeApi.hashAssets);
verifyNever(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('processes assets when available', () async {
@@ -50,17 +55,15 @@ 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.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(
() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false),
).thenAnswer((_) async => [result]);
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]);
await sut.hashAssets();
verify(() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false)).called(1);
verify(() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false)).called(1);
final captured =
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
expect(captured.length, 1);
expect(captured[asset.id], result.hash);
});
@@ -69,16 +72,16 @@ void main() {
final album = LocalAlbumFactory.create();
final asset = LocalAssetFactory.create();
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(
() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false),
() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false),
).thenAnswer((_) async => [HashResult(assetId: asset.id, error: 'Failed to hash')]);
await sut.hashAssets();
final captured =
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
expect(captured.length, 0);
});
@@ -86,25 +89,25 @@ void main() {
final album = LocalAlbumFactory.create();
final asset = LocalAssetFactory.create();
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(
() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false),
() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false),
).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: null)]);
await sut.hashAssets();
final captured =
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
verify(() => mocks.localAsset.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.repo,
localAssetRepository: mocks.localAsset.repo,
nativeSyncApi: mocks.nativeApi.api,
localAlbumRepository: mocks.localAlbum,
localAssetRepository: mocks.localAsset,
nativeSyncApi: mocks.nativeApi,
batchSize: batchSize,
trashedLocalAssetRepository: mocks.trashedAsset,
);
@@ -116,9 +119,12 @@ void main() {
final capturedCalls = <List<String>>[];
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 {
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 {
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();
@@ -130,7 +136,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.repo.updateHashes(any())).called(2);
verify(() => mocks.localAsset.updateHashes(any())).called(2);
});
test('handles mixed success and failure in batch', () async {
@@ -138,9 +144,9 @@ void main() {
final asset1 = LocalAssetFactory.create();
final asset2 = LocalAssetFactory.create();
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(
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(
(_) async => [
HashResult(assetId: asset1.id, hash: 'asset1-hash'),
HashResult(assetId: asset2.id, error: 'Failed to hash asset2'),
@@ -150,7 +156,7 @@ void main() {
await sut.hashAssets();
final captured =
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
expect(captured.length, 1);
expect(captured[asset1.id], 'asset1-hash');
});
@@ -161,18 +167,20 @@ void main() {
final asset1 = LocalAssetFactory.create();
final asset2 = LocalAssetFactory.create();
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 {
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 {
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.api.hashAssets([asset1.id], allowNetworkAccess: true)).called(1);
verify(() => mocks.nativeApi.api.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
verify(() => mocks.nativeApi.hashAssets([asset1.id], allowNetworkAccess: true)).called(1);
verify(() => mocks.nativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
});
});
});
@@ -0,0 +1,100 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import '../factories/remote_asset_factory.dart';
void main() {
// total must exceed the sliding buffer so it cannot hold the whole library at once
const total = 2000;
late List<BaseAsset> all;
late TimelineService sut;
TimelineService buildService() {
all = List.generate(total, (i) => RemoteAssetFactory.create(id: 'a${i.toString().padLeft(5, '0')}'));
return TimelineService((
assetSource: (index, count) async {
final end = (index + count) > total ? total : index + count;
return all.sublist(index, end);
},
bucketSource: () => Stream.value([const Bucket(assetCount: total)]),
origin: TimelineOrigin.main,
));
}
Future<void> settle() => Future.delayed(const Duration(milliseconds: 10));
setUp(() async {
sut = buildService();
await settle(); // let the bucket subscription load the first batch and set totalAssets
});
tearDown(() async {
await sut.dispose();
});
test('buffer holds the first batch but not the whole library', () {
expect(sut.totalAssets, total);
expect(sut.hasRange(0, kTimelineAssetLoadBatchSize), isTrue);
expect(sut.hasRange(0, total), isFalse);
});
// #27118 / #20855 mechanism: drag-selecting from a low anchor while the grid
// auto-scrolls down slides the buffer forward to follow the finger. once the
// buffer offset passes the anchor, hasRange(anchor, ...) is false and
// _handleDragAssetEnter silently stops extending the selection.
test('anchor drops out of the buffer after the grid scrolls down', () async {
const anchor = 5;
expect(sut.hasRange(anchor, 100), isTrue);
// the grid loads a far-down range as it auto-scrolls during the drag
await sut.loadAssets(1500, 1);
const current = 1500;
const count = current - anchor + 1;
expect(sut.hasRange(anchor, 1), isFalse, reason: 'anchor is now below the buffer offset');
expect(sut.hasRange(anchor, count), isFalse, reason: 'the full drag range is no longer resident');
expect(() => sut.getAssets(anchor, count), throwsRangeError);
});
// the fix: getAssetsRange returns the whole drag range regardless of the
// buffer position, so the selection keeps extending while scrolling.
group('getAssetsRange', () {
test('returns a buffered range', () async {
final assets = await sut.getAssetsRange(0, 50);
expect(assets.length, 50);
expect(assets.first, all[0]);
expect(assets.last, all[49]);
});
test('returns a range wider than the buffer', () async {
final assets = await sut.getAssetsRange(0, total);
expect(assets.length, total);
expect(assets.first, all[0]);
expect(assets.last, all[total - 1]);
});
test('returns the anchor range after the buffer scrolled past the anchor', () async {
const anchor = 5;
await sut.loadAssets(1500, 1); // buffer slides forward, dropping the anchor
expect(sut.hasRange(anchor, 1), isFalse);
const current = 1500;
const count = current - anchor + 1;
final assets = await sut.getAssetsRange(anchor, count);
expect(assets.length, count);
expect(assets.first, all[anchor]);
expect(assets.last, all[current]);
});
test('clamps a range that runs past the end and ignores invalid input', () async {
final tail = await sut.getAssetsRange(total - 10, 100);
expect(tail.length, 10);
expect(await sut.getAssetsRange(-1, 10), isEmpty);
expect(await sut.getAssetsRange(0, 0), isEmpty);
expect(await sut.getAssetsRange(total, 10), isEmpty);
});
});
}
+9 -33
View File
@@ -1,51 +1,27 @@
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 a localized Material app such that it
/// becomes:
///
/// EasyLocalization
/// |-ProviderScope
/// |-MaterialApp (localization delegates wired up)
/// |-Material
/// |-[widget]
/// Wraps the provided [widget] with Material app such that it becomes:
///
/// ProviderScope
/// |-MaterialApp
/// |-Material
/// |-[widget]
Future<void> pumpConsumerWidget(
Widget widget, {
Duration? duration,
EnginePhase phase = EnginePhase.sendSemanticsUpdate,
List<Override> overrides = const [],
}) async {
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),
),
),
),
return pumpWidget(
ProviderScope(
overrides: overrides,
child: MaterialApp(debugShowCheckedModeBanner: false, home: Material(child: widget)),
),
duration: duration,
phase: phase,
);
await pumpAndSettle();
}
}
-5
View File
@@ -1,5 +0,0 @@
/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)
-619
View File
@@ -1,619 +0,0 @@
# 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"
-44
View File
@@ -1,44 +0,0 @@
[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
-61
View File
@@ -1,61 +0,0 @@
# 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).
-17
View File
@@ -1,17 +0,0 @@
[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
-75
View File
@@ -1,75 +0,0 @@
//! 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());
}
}
-173
View File
@@ -1,173 +0,0 @@
//! 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
}
}
-26
View File
@@ -1,26 +0,0 @@
//! 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());
}
}
-21
View File
@@ -1,21 +0,0 @@
[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
-19
View File
@@ -1,19 +0,0 @@
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}"),
}
}
@@ -1,3 +0,0 @@
language = "C"
pragma_once = true
autogen_warning = "// Generated by cbindgen — do not edit."
@@ -1,60 +0,0 @@
#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);
@@ -1,18 +0,0 @@
# 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",
]
-197
View File
@@ -1,197 +0,0 @@
//! 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());
}
}
-19
View File
@@ -1,19 +0,0 @@
[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
-3
View File
@@ -1,3 +0,0 @@
fn main() {
napi_build::setup();
}
-24
View File
@@ -1,24 +0,0 @@
//! 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()))
}
-33
View File
@@ -1,33 +0,0 @@
# 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/
-48
View File
@@ -1,48 +0,0 @@
# 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).
@@ -1,4 +0,0 @@
include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
-20
View File
@@ -1,20 +0,0 @@
# 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
-14
View File
@@ -1,14 +0,0 @@
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);
});
}
@@ -1,8 +0,0 @@
/// 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';
@@ -1,5 +0,0 @@
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');
@@ -1,75 +0,0 @@
// 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);
@@ -1,19 +0,0 @@
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);
}
}
@@ -1,34 +0,0 @@
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);
}
}
@@ -1,42 +0,0 @@
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);
}
}
-26
View File
@@ -1,26 +0,0 @@
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
@@ -1,53 +0,0 @@
// 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));
});
}
@@ -1,82 +0,0 @@
// 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)));
}
-68
View File
@@ -1,68 +0,0 @@
[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"]
-17
View File
@@ -1,17 +0,0 @@
#!/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"
-31
View File
@@ -1,31 +0,0 @@
// 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');
}
-17
View File
@@ -1,17 +0,0 @@
// 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');