mirror of
https://github.com/immich-app/immich.git
synced 2026-07-16 13:44:28 +03:00
Compare commits
3 Commits
renovate/n
...
fix/slides
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f29f884f0b | ||
|
|
52562c4972 | ||
|
|
fc65603699 |
56
.github/workflows/fdroid.yml
vendored
56
.github/workflows/fdroid.yml
vendored
@@ -1,56 +0,0 @@
|
||||
name: Update F-Droid Repo
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
update-index:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout pubspec for versionCode
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name }}
|
||||
persist-credentials: false
|
||||
sparse-checkout: mobile/pubspec.yaml
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Update F-Droid repo
|
||||
env:
|
||||
GITLAB_TOKEN: ${{ secrets.FDROID_REPO_TOKEN }}
|
||||
RELEASE: ${{ toJSON(github.event.release) }}
|
||||
TAG: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
VERSION_CODE=$(yq '.version' mobile/pubspec.yaml | cut -d+ -f2)
|
||||
export VERSION_CODE
|
||||
|
||||
NEW_ENTRY=$(yq -P '
|
||||
[.assets[] | select(.name == "app-release.apk")] as $matches |
|
||||
with(select($matches | length == 0); error("app-release.apk not found in release assets")) |
|
||||
{
|
||||
"url": $matches[0].browser_download_url,
|
||||
"sha256sum": ($matches[0].digest | sub("^sha256:", "")),
|
||||
"date": (.published_at | split("T") | .[0]),
|
||||
"version-code": env(VERSION_CODE)
|
||||
} | .date tag= "!!timestamp"
|
||||
' <<< "$RELEASE")
|
||||
export NEW_ENTRY
|
||||
|
||||
git clone --depth 1 "https://oauth2:${GITLAB_TOKEN}@gitlab.futo.org/fdroid/repo-v2.git" fdroid-repo
|
||||
cd fdroid-repo
|
||||
|
||||
yq -i '
|
||||
with(select([.apks[] | select(."version-code" == env(VERSION_CODE))] | length > 0); error("version-code already in index")) |
|
||||
.apks = [env(NEW_ENTRY)] + .apks
|
||||
' apps/Immich/index.yml
|
||||
|
||||
git config user.name "Immich Release Bot"
|
||||
git config user.email "bot@immich.app"
|
||||
git commit -am "Immich: add version-code ${VERSION_CODE} (${TAG})"
|
||||
git push
|
||||
@@ -32,7 +32,7 @@
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@socket.io/component-emitter": "^3.1.2",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/pg": "^8.15.1",
|
||||
"@types/pngjs": "^6.0.4",
|
||||
"@types/supertest": "^7.0.0",
|
||||
|
||||
@@ -796,22 +796,5 @@ describe('/albums', () => {
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest('Not found or no album.share access'));
|
||||
});
|
||||
|
||||
it('should not allow an editor to change the role of an owner', async () => {
|
||||
const album = await utils.createAlbum(user1.accessToken, {
|
||||
albumName: 'testAlbum',
|
||||
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Editor }],
|
||||
});
|
||||
|
||||
expect(album.albumUsers[1].role).toEqual(AlbumUserRole.Editor);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.put(`/albums/${album.id}/user/${user1.userId}`)
|
||||
.set('Authorization', `Bearer ${user2.accessToken}`)
|
||||
.send({ role: AlbumUserRole.Editor });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest('User is owner'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1621,7 +1621,7 @@
|
||||
"person": "Person",
|
||||
"person_age_months": "{months, plural, one {# month} other {# months}} old",
|
||||
"person_age_year_months": "1 year, {months, plural, one {# month} other {# months}} old",
|
||||
"person_age_years": "{years, plural, one {# year} other {# years}} old",
|
||||
"person_age_years": "{years, plural, other {# years}} old",
|
||||
"person_birthdate": "Born on {date}",
|
||||
"person_hidden": "{name}{hidden, select, true { (hidden)} other {}}",
|
||||
"person_recognized": "Person recognized",
|
||||
|
||||
@@ -26,6 +26,7 @@ const String kDownloadGroupLivePhoto = 'group_livephoto';
|
||||
const String kShareDownloadGroup = 'group_share';
|
||||
|
||||
// Timeline constants
|
||||
const int kTimelineNoneSegmentSize = 120;
|
||||
const int kTimelineAssetLoadBatchSize = 1024;
|
||||
const int kTimelineAssetLoadOppositeSize = 64;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
@@ -684,7 +685,16 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
}
|
||||
}
|
||||
|
||||
List<Bucket> _generateBuckets(int count) => count == 0 ? const [] : [Bucket(assetCount: count)];
|
||||
List<Bucket> _generateBuckets(int count) {
|
||||
final buckets = List.filled(
|
||||
(count / kTimelineNoneSegmentSize).ceil(),
|
||||
const Bucket(assetCount: kTimelineNoneSegmentSize),
|
||||
);
|
||||
if (count % kTimelineNoneSegmentSize != 0) {
|
||||
buckets[buckets.length - 1] = Bucket(assetCount: count % kTimelineNoneSegmentSize);
|
||||
}
|
||||
return buckets;
|
||||
}
|
||||
|
||||
extension on Expression<DateTime> {
|
||||
Expression<String> dateFmt(GroupAssetsBy groupBy, {bool toLocal = false}) {
|
||||
|
||||
@@ -51,6 +51,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
|
||||
int? _crossfadeFromIndex;
|
||||
int? _crossfadeToIndex;
|
||||
int _zoomCycle = 0;
|
||||
bool _disableAnimations = false;
|
||||
|
||||
@override
|
||||
initState() {
|
||||
@@ -70,6 +71,12 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
|
||||
unawaited(WakelockPlus.enable());
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_disableAnimations = MediaQuery.disableAnimationsOf(context);
|
||||
}
|
||||
|
||||
@override
|
||||
dispose() {
|
||||
_timer.cancel();
|
||||
@@ -166,6 +173,11 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
|
||||
}
|
||||
|
||||
void _crossFadeToPage(int page) {
|
||||
if (_disableAnimations) {
|
||||
_pageController.jumpToPage(page);
|
||||
return;
|
||||
}
|
||||
|
||||
final previousIndex = _index;
|
||||
_pageController.jumpToPage(page);
|
||||
setState(() {
|
||||
@@ -273,19 +285,12 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
|
||||
}
|
||||
|
||||
if (asset.isImage) {
|
||||
final elapsed = _stopwatch.elapsedMilliseconds;
|
||||
final duration = _config.duration * 1000;
|
||||
|
||||
return TweenAnimationBuilder(
|
||||
return _SlideshowProgressBar(
|
||||
key: Key(_index.toString()),
|
||||
tween: Tween<double>(begin: elapsed / duration.toDouble(), end: _paused ? elapsed / duration.toDouble() : 1.0),
|
||||
duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)),
|
||||
builder: (context, value, _) => LinearProgressIndicator(
|
||||
color: context.colorScheme.primary,
|
||||
borderRadius: const BorderRadius.all(Radius.zero),
|
||||
minHeight: 5,
|
||||
value: value,
|
||||
),
|
||||
durationMs: _config.duration * 1000,
|
||||
elapsedMs: _stopwatch.elapsedMilliseconds,
|
||||
paused: _paused,
|
||||
color: context.colorScheme.primary,
|
||||
);
|
||||
} else {
|
||||
return LinearProgressIndicator(
|
||||
@@ -334,6 +339,21 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
|
||||
final imageProvider = getFullImageProvider(asset, size: context.sizeData);
|
||||
|
||||
if (asset.isImage) {
|
||||
PhotoView buildPhotoView(PhotoViewComputedScale initialScale) => PhotoView(
|
||||
imageProvider: imageProvider,
|
||||
index: index,
|
||||
disableScaleGestures: true,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.high,
|
||||
initialScale: initialScale,
|
||||
controller: PhotoViewController(),
|
||||
onTapUp: (_, _, _) => _onTapUp(),
|
||||
);
|
||||
|
||||
if (_disableAnimations) {
|
||||
return buildPhotoView(scale);
|
||||
}
|
||||
|
||||
final zoomOut = _zoomCycle.isOdd;
|
||||
final elapsed = _stopwatch.elapsedMilliseconds;
|
||||
final duration = _config.duration * 1000;
|
||||
@@ -349,16 +369,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
|
||||
: 1.0,
|
||||
),
|
||||
duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)),
|
||||
builder: (context, value, _) => PhotoView(
|
||||
imageProvider: imageProvider,
|
||||
index: index,
|
||||
disableScaleGestures: true,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.high,
|
||||
initialScale: scale * (1.0 + value * _kenBurnsZoom),
|
||||
controller: PhotoViewController(),
|
||||
onTapUp: (_, _, _) => _onTapUp(),
|
||||
),
|
||||
builder: (context, value, _) => buildPhotoView(scale * (1.0 + value * _kenBurnsZoom)),
|
||||
);
|
||||
} else {
|
||||
final status = ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.status));
|
||||
@@ -463,3 +474,75 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Progress bar for image slides, driven by an explicit [AnimationController].
|
||||
///
|
||||
/// [TweenAnimationBuilder] creates its controller internally with the default
|
||||
/// [AnimationBehavior.normal], which makes it run ~20x too fast while the system
|
||||
/// "reduce motion" setting is on (flutter/flutter#164287). This owns its
|
||||
/// controller so it can use [AnimationBehavior.preserve] and animate at the real
|
||||
/// slide duration regardless of that setting.
|
||||
class _SlideshowProgressBar extends StatefulWidget {
|
||||
final int durationMs;
|
||||
final int elapsedMs;
|
||||
final bool paused;
|
||||
final Color color;
|
||||
|
||||
const _SlideshowProgressBar({
|
||||
super.key,
|
||||
required this.durationMs,
|
||||
required this.elapsedMs,
|
||||
required this.paused,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_SlideshowProgressBar> createState() => _SlideshowProgressBarState();
|
||||
}
|
||||
|
||||
class _SlideshowProgressBarState extends State<_SlideshowProgressBar> with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: Duration(milliseconds: widget.durationMs),
|
||||
animationBehavior: AnimationBehavior.preserve,
|
||||
)..value = (widget.elapsedMs / widget.durationMs).clamp(0.0, 1.0);
|
||||
if (!widget.paused) {
|
||||
_controller.forward();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_SlideshowProgressBar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.durationMs != oldWidget.durationMs) {
|
||||
_controller.duration = Duration(milliseconds: widget.durationMs);
|
||||
}
|
||||
if (widget.paused != oldWidget.paused) {
|
||||
widget.paused ? _controller.stop() : _controller.forward();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, _) => LinearProgressIndicator(
|
||||
color: widget.color,
|
||||
borderRadius: const BorderRadius.all(Radius.zero),
|
||||
minHeight: 5,
|
||||
value: _controller.value,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,12 +81,6 @@ class AssetViewer extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
static const _viewerOverlayStyle = SystemUiOverlayStyle(
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
statusBarBrightness: Brightness.dark,
|
||||
systemNavigationBarIconBrightness: Brightness.light,
|
||||
);
|
||||
|
||||
late final _heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
|
||||
late final _pageController = PageController(initialPage: widget.initialIndex);
|
||||
late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted);
|
||||
@@ -286,52 +280,49 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
_setSystemUIMode(controls, details);
|
||||
});
|
||||
|
||||
return AnnotatedRegion(
|
||||
value: _viewerOverlayStyle,
|
||||
child: Scaffold(
|
||||
backgroundColor: backgroundColor,
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: const ViewerTopAppBar(),
|
||||
extendBody: true,
|
||||
extendBodyBehindAppBar: true,
|
||||
floatingActionButton: IgnorePointer(
|
||||
ignoring: !showingControls,
|
||||
child: AnimatedOpacity(
|
||||
opacity: showingControls ? 1.0 : 0.0,
|
||||
duration: Durations.short2,
|
||||
child: const DownloadStatusFloatingButton(),
|
||||
),
|
||||
return Scaffold(
|
||||
backgroundColor: backgroundColor,
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: const ViewerTopAppBar(),
|
||||
extendBody: true,
|
||||
extendBodyBehindAppBar: true,
|
||||
floatingActionButton: IgnorePointer(
|
||||
ignoring: !showingControls,
|
||||
child: AnimatedOpacity(
|
||||
opacity: showingControls ? 1.0 : 0.0,
|
||||
duration: Durations.short2,
|
||||
child: const DownloadStatusFloatingButton(),
|
||||
),
|
||||
bottomNavigationBar: const ViewerBottomAppBar(),
|
||||
body: Stack(
|
||||
children: [
|
||||
NotificationListener<ScrollEndNotification>(
|
||||
onNotification: _onScrollEnd,
|
||||
child: PhotoViewGestureDetectorScope(
|
||||
axis: Axis.horizontal,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
physics: isZoomed
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: CurrentPlatform.isIOS
|
||||
? const FastScrollPhysics()
|
||||
: const FastClampingScrollPhysics(),
|
||||
itemCount: _totalAssets,
|
||||
itemBuilder: (context, index) =>
|
||||
AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: const ViewerBottomAppBar(),
|
||||
body: Stack(
|
||||
children: [
|
||||
NotificationListener<ScrollEndNotification>(
|
||||
onNotification: _onScrollEnd,
|
||||
child: PhotoViewGestureDetectorScope(
|
||||
axis: Axis.horizontal,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
physics: isZoomed
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: CurrentPlatform.isIOS
|
||||
? const FastScrollPhysics()
|
||||
: const FastClampingScrollPhysics(),
|
||||
itemCount: _totalAssets,
|
||||
itemBuilder: (context, index) =>
|
||||
AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate),
|
||||
),
|
||||
),
|
||||
if (!CurrentPlatform.isIOS)
|
||||
IgnorePointer(
|
||||
child: AnimatedContainer(
|
||||
duration: Durations.short2,
|
||||
color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0),
|
||||
height: context.padding.top,
|
||||
),
|
||||
),
|
||||
if (!CurrentPlatform.isIOS)
|
||||
IgnorePointer(
|
||||
child: AnimatedContainer(
|
||||
duration: Durations.short2,
|
||||
color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0),
|
||||
height: context.padding.top,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -317,8 +317,6 @@ class BackgroundUploadService {
|
||||
priority: priority,
|
||||
isFavorite: asset.isFavorite,
|
||||
requiresWiFi: requiresWiFi,
|
||||
// Visibility hidden on upload to prevent the server from running regular jobs on the live photo asset
|
||||
fields: entity.isLivePhoto ? {'visibility': api.AssetVisibility.hidden.value} : null,
|
||||
cloudId: entity.isLivePhoto ? null : asset.cloudId,
|
||||
adjustmentTime: entity.isLivePhoto ? null : asset.adjustmentTime?.toIso8601String(),
|
||||
latitude: entity.isLivePhoto ? null : asset.latitude?.toString(),
|
||||
@@ -338,7 +336,8 @@ class BackgroundUploadService {
|
||||
return null;
|
||||
}
|
||||
|
||||
final fields = {'livePhotoVideoId': livePhotoVideoId};
|
||||
// Visibility hidden on upload to prevent the server from running regular jobs on the live photo asset
|
||||
final fields = {'livePhotoVideoId': livePhotoVideoId, 'visibility': api.AssetVisibility.hidden.value};
|
||||
|
||||
final requiresWiFi = _shouldRequireWiFi(asset);
|
||||
final originalFileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' hide AssetVisibility;
|
||||
@@ -101,7 +100,7 @@ class ForegroundUploadService {
|
||||
final requireWifi = _shouldRequireWiFi(asset);
|
||||
return requireWifi && !hasWifi;
|
||||
},
|
||||
processItem: (asset) => uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
|
||||
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -127,7 +126,7 @@ class ForegroundUploadService {
|
||||
continue;
|
||||
}
|
||||
|
||||
await uploadSingleAsset(asset, cancelToken, callbacks: callbacks);
|
||||
await _uploadSingleAsset(asset, cancelToken, callbacks: callbacks);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +143,7 @@ class ForegroundUploadService {
|
||||
await _executeWithWorkerPool<LocalAsset>(
|
||||
items: localAssets,
|
||||
cancelToken: cancelToken,
|
||||
processItem: (asset) => uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
|
||||
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -234,8 +233,7 @@ class ForegroundUploadService {
|
||||
await Future.wait(workerFutures);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<void> uploadSingleAsset(
|
||||
Future<void> _uploadSingleAsset(
|
||||
LocalAsset asset,
|
||||
Completer<void>? cancelToken, {
|
||||
required UploadCallbacks callbacks,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||
import 'package:immich_mobile/repositories/partner_api.repository.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
@@ -8,5 +7,3 @@ class MockSyncApi extends Mock implements SyncApi {}
|
||||
class MockServerApi extends Mock implements ServerApi {}
|
||||
|
||||
class MockPartnerApiRepository extends Mock implements PartnerApiRepository {}
|
||||
|
||||
class MockConnectivityApi extends Mock implements ConnectivityApi {}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/generated/codegen_loader.g.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
|
||||
import '../../../fixtures/asset.stub.dart';
|
||||
|
||||
class _SeededAssetViewerNotifier extends AssetViewerStateNotifier {
|
||||
@override
|
||||
AssetViewerState build() {
|
||||
super.build();
|
||||
return AssetViewerState(currentAsset: LocalAssetStub.image1);
|
||||
}
|
||||
}
|
||||
|
||||
TimelineService _stubTimelineService() {
|
||||
return TimelineService((
|
||||
assetSource: (index, count) async => [LocalAssetStub.image1],
|
||||
bucketSource: () => Stream.value(const [Bucket(assetCount: 1)]),
|
||||
origin: TimelineOrigin.main,
|
||||
));
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('status bar icons are light while the asset viewer is open in light mode', (tester) async {
|
||||
// Emulate arriving from a light-themed page whose AppBar set dark status
|
||||
// bar icons (the state the viewer is opened from in light mode).
|
||||
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.dark);
|
||||
|
||||
await tester.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: [
|
||||
timelineServiceProvider.overrideWithValue(_stubTimelineService()),
|
||||
assetViewerProvider.overrideWith(_SeededAssetViewerNotifier.new),
|
||||
],
|
||||
child: Builder(
|
||||
builder: (context) => MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
localizationsDelegates: context.localizationDelegates,
|
||||
supportedLocales: context.supportedLocales,
|
||||
locale: context.locale,
|
||||
home: const Material(child: AssetViewer(initialIndex: 0)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Let post-frame callbacks and the initial frames run without waiting for
|
||||
// infinite animations (loading spinners) to settle.
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 600));
|
||||
|
||||
// Asset thumbnails cannot load in the test environment (no platform
|
||||
// channels / real HTTP). Those image errors are irrelevant to the system
|
||||
// chrome behaviour under test, so drain them.
|
||||
tester.takeException();
|
||||
|
||||
expect(
|
||||
SystemChrome.latestStyle?.statusBarIconBrightness,
|
||||
Brightness.light,
|
||||
reason:
|
||||
'The asset viewer draws over a black background, so status bar '
|
||||
'icons must be light regardless of the app theme',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -118,24 +118,8 @@ void main() {
|
||||
expect(task, isNotNull);
|
||||
// For live photos, extension should be changed to match the video file
|
||||
expect(task!.fields['filename'], equals('OriginalLivePhoto.mov'));
|
||||
expect(task.fields['visibility'], equals('hidden'));
|
||||
verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1);
|
||||
});
|
||||
|
||||
test('should not set visibility for a regular photo', () async {
|
||||
final asset = LocalAssetStub.image1;
|
||||
final mockEntity = MockAssetEntity();
|
||||
final mockFile = File('/path/to/file.jpg');
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'Regular.jpg');
|
||||
|
||||
final task = await sut.getUploadTask(asset);
|
||||
expect(task, isNotNull);
|
||||
expect(task!.fields.containsKey('visibility'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('getLivePhotoUploadTask', () {
|
||||
@@ -156,7 +140,7 @@ void main() {
|
||||
expect(task, isNotNull);
|
||||
expect(task!.fields['filename'], equals('OriginalLivePhoto.HEIC'));
|
||||
expect(task.fields['livePhotoVideoId'], equals('video-id-123'));
|
||||
expect(task.fields.containsKey('visibility'), isFalse);
|
||||
expect(task.fields['visibility'], equals('hidden'));
|
||||
verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1);
|
||||
});
|
||||
|
||||
@@ -350,7 +334,7 @@ void main() {
|
||||
expect(task, isNotNull);
|
||||
expect(task!.fields.containsKey('metadata'), isTrue);
|
||||
expect(task.fields['livePhotoVideoId'], equals('video-123'));
|
||||
expect(task.fields.containsKey('visibility'), isFalse);
|
||||
expect(task.fields['visibility'], equals('hidden'));
|
||||
|
||||
final metadata = jsonDecode(task.fields['metadata']!) as List;
|
||||
expect(metadata, hasLength(1));
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart' hide isNull, isNotNull;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../api.mocks.dart';
|
||||
import '../fixtures/asset.stub.dart';
|
||||
import '../infrastructure/repository.mock.dart';
|
||||
import '../mocks/asset_entity.mock.dart';
|
||||
import '../repository.mocks.dart';
|
||||
|
||||
void main() {
|
||||
late ForegroundUploadService sut;
|
||||
late MockUploadRepository mockUploadRepository;
|
||||
late MockStorageRepository mockStorageRepository;
|
||||
late MockDriftBackupRepository mockBackupRepository;
|
||||
late MockConnectivityApi mockConnectivityApi;
|
||||
late MockAssetMediaRepository mockAssetMediaRepository;
|
||||
late Drift db;
|
||||
|
||||
setUpAll(() async {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
const MethodChannel('plugins.flutter.io/path_provider'),
|
||||
(MethodCall methodCall) async => 'test',
|
||||
);
|
||||
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
|
||||
await StoreService.init(storeRepository: DriftStoreRepository(db));
|
||||
await SettingsRepository.ensureInitialized(db);
|
||||
|
||||
await Store.put(StoreKey.serverEndpoint, 'http://demo.immich.app');
|
||||
await Store.put(StoreKey.deviceId, 'device-id');
|
||||
|
||||
registerFallbackValue(File('file'));
|
||||
registerFallbackValue(<String, String>{});
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
mockUploadRepository = MockUploadRepository();
|
||||
mockStorageRepository = MockStorageRepository();
|
||||
mockBackupRepository = MockDriftBackupRepository();
|
||||
mockConnectivityApi = MockConnectivityApi();
|
||||
mockAssetMediaRepository = MockAssetMediaRepository();
|
||||
|
||||
sut = ForegroundUploadService(
|
||||
mockUploadRepository,
|
||||
mockStorageRepository,
|
||||
mockBackupRepository,
|
||||
mockConnectivityApi,
|
||||
mockAssetMediaRepository,
|
||||
);
|
||||
});
|
||||
|
||||
List<Map<String, String>> captureFields() {
|
||||
final captured = <Map<String, String>>[];
|
||||
when(
|
||||
() => mockUploadRepository.uploadFile(
|
||||
file: any(named: 'file'),
|
||||
originalFileName: any(named: 'originalFileName'),
|
||||
fields: any(named: 'fields'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
onProgress: any(named: 'onProgress'),
|
||||
logContext: any(named: 'logContext'),
|
||||
),
|
||||
).thenAnswer((invocation) async {
|
||||
final fields = invocation.namedArguments[#fields] as Map<String, String>;
|
||||
captured.add(Map.of(fields));
|
||||
return UploadResult.success(remoteAssetId: 'remote-${captured.length}');
|
||||
});
|
||||
return captured;
|
||||
}
|
||||
|
||||
group('uploadSingleAsset', () {
|
||||
test('should upload the motion part hidden and keep the still image visible', () async {
|
||||
final asset = LocalAssetStub.image1;
|
||||
final mockEntity = MockAssetEntity();
|
||||
final stillFile = File('/path/to/still.heic');
|
||||
final videoFile = File('/path/to/motion.mov');
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(true);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile);
|
||||
when(() => mockStorageRepository.getMotionFileForAsset(asset)).thenAnswer((_) async => videoFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'live.heic');
|
||||
|
||||
final captured = captureFields();
|
||||
|
||||
await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks());
|
||||
|
||||
expect(captured, hasLength(2));
|
||||
expect(captured[0]['visibility'], equals('hidden'));
|
||||
expect(captured[0].containsKey('livePhotoVideoId'), isFalse);
|
||||
expect(captured[1].containsKey('visibility'), isFalse);
|
||||
expect(captured[1]['livePhotoVideoId'], equals('remote-1'));
|
||||
});
|
||||
|
||||
test('should not set visibility for a regular photo', () async {
|
||||
final asset = LocalAssetStub.image1;
|
||||
final mockEntity = MockAssetEntity();
|
||||
final stillFile = File('/path/to/photo.jpg');
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'photo.jpg');
|
||||
|
||||
final captured = captureFields();
|
||||
|
||||
await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks());
|
||||
|
||||
expect(captured, hasLength(1));
|
||||
expect(captured[0].containsKey('visibility'), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -25,7 +25,7 @@
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/micromatch": "^4.0.9",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/node": "^24.13.2",
|
||||
"@vitest/coverage-v8": "^4.0.0",
|
||||
"byte-size": "^9.0.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"devDependencies": {
|
||||
"@extism/js-pdk": "^1.1.1",
|
||||
"@immich/sdk": "workspace:*",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/node": "^24.13.2",
|
||||
"esbuild": "^0.28.0",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"typescript": "^6.0.0"
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"semver": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/semver": "^7.7.1",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.8"
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"@oazapfts/runtime": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/node": "^24.13.2",
|
||||
"typescript": "^6.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
575
pnpm-lock.yaml
generated
575
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -138,7 +138,7 @@
|
||||
"@types/luxon": "^3.6.2",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/picomatch": "^4.0.0",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
// (#29836) Reset the visibility of any still images that are hidden and have a motion part
|
||||
await sql`
|
||||
UPDATE "asset"
|
||||
SET "visibility" = 'timeline'
|
||||
WHERE "type" = 'IMAGE'
|
||||
AND "visibility" = 'hidden'
|
||||
AND "livePhotoVideoId" IS NOT NULL
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(): Promise<void> {
|
||||
// Not implemented: the previous 'hidden' value was itself the bug.
|
||||
}
|
||||
@@ -663,7 +663,6 @@ describe(AlbumService.name, () => {
|
||||
const album = AlbumFactory.from().albumUser({ userId: user.id }).build();
|
||||
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.albumUser.update.mockResolvedValue();
|
||||
|
||||
await sut.updateUser(AuthFactory.create(owner), album.id, user.id, { role: AlbumUserRole.Viewer });
|
||||
|
||||
@@ -335,14 +335,6 @@ export class AlbumService extends BaseService {
|
||||
|
||||
async updateUser(auth: AuthDto, id: string, userId: string, dto: UpdateAlbumUserDto): Promise<void> {
|
||||
await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] });
|
||||
|
||||
const album = await this.findOrFail(id, userId, { withAssets: false });
|
||||
const owner = album.albumUsers[0];
|
||||
|
||||
if (owner.user.id === userId) {
|
||||
throw new BadRequestException('User is owner');
|
||||
}
|
||||
|
||||
await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role });
|
||||
}
|
||||
|
||||
|
||||
@@ -118,11 +118,7 @@ export class SearchService extends BaseService {
|
||||
}
|
||||
|
||||
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
|
||||
const items = await this.searchRepository.searchRandom(dto.size || 250, {
|
||||
...dto,
|
||||
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
|
||||
userIds,
|
||||
});
|
||||
const items = await this.searchRepository.searchRandom(dto.size || 250, { ...dto, userIds });
|
||||
return items.map((item) => mapAsset(item, { auth }));
|
||||
}
|
||||
|
||||
|
||||
@@ -196,19 +196,4 @@ describe(SearchService.name, () => {
|
||||
expect(suggestions).toEqual(['Canon', null]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchRandom', () => {
|
||||
it('should filter out locked assets in a default session', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
|
||||
await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Locked });
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
|
||||
const response = await sut.searchRandom(auth, {});
|
||||
|
||||
expect(response.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
const { trigger, selectedKey, onClose }: Props = $props();
|
||||
</script>
|
||||
|
||||
<BasicModal title={$t('add_step')} onClose={() => onClose()} size="medium">
|
||||
<BasicModal title={$t('add_step')} {onClose} size="medium">
|
||||
{#await searchPluginMethods({ trigger })}
|
||||
<div class="flex w-full place-content-center place-items-center">
|
||||
<LoadingSpinner />
|
||||
|
||||
Reference in New Issue
Block a user