mirror of
https://github.com/immich-app/immich.git
synced 2026-07-07 04:57:05 -07:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1039261be | |||
| fbc3a8c7ec | |||
| 2f87ce154b | |||
| c04acc8951 | |||
| af32a8ca7c |
@@ -103,14 +103,14 @@ jobs:
|
||||
working-directory: ./mobile
|
||||
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
|
||||
|
||||
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
- uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
- name: Restore Gradle Cache
|
||||
id: cache-gradle-restore
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
|
||||
- name: Save Gradle Cache
|
||||
id: cache-gradle-save
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
|
||||
if: github.ref == 'refs/heads/main'
|
||||
with:
|
||||
path: |
|
||||
@@ -237,7 +237,7 @@ jobs:
|
||||
run: flutter build ios --config-only --no-codesign
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@89f90524b88a01fe6e0b732220432cc6142926af # v1.313.0
|
||||
uses: ruby/setup-ruby@0dafeac902942906541bc140009cdbf32665b601 # v1.315.0
|
||||
with:
|
||||
ruby-version: '3.3'
|
||||
bundler-cache: true
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for breaking API changes
|
||||
uses: oasdiff/oasdiff-action/breaking@e24529087d93f837b28b50bb66ba9016380a7fcc # v0.1.2
|
||||
uses: oasdiff/oasdiff-action/breaking@ae5ef6402ebe218ac6e872c59d0fa4557694e75c # v0.1.4
|
||||
with:
|
||||
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
||||
revision: open-api/immich-openapi-specs.json
|
||||
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
@@ -149,7 +149,7 @@ jobs:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
- name: Create draft release
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
||||
with:
|
||||
draft: true
|
||||
prerelease: ${{ needs.bump_version.outputs.rc }}
|
||||
|
||||
@@ -79,17 +79,6 @@ sealed class BaseAsset {
|
||||
bool get isLocalOnly => storage == AssetState.local;
|
||||
bool get isRemoteOnly => storage == AssetState.remote;
|
||||
|
||||
// Same asset even if localId is known on one side but not the other (heroTag isn't stable then)
|
||||
bool isSameAsset(BaseAsset other) {
|
||||
if (remoteId != null && other.remoteId != null) {
|
||||
return remoteId == other.remoteId;
|
||||
}
|
||||
if (localId != null && other.localId != null) {
|
||||
return localId == other.localId;
|
||||
}
|
||||
return checksum != null && checksum == other.checksum;
|
||||
}
|
||||
|
||||
bool get isEditable => false;
|
||||
|
||||
// Overridden in subclasses
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
||||
import 'package:immich_mobile/utils/async_mutex.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
typedef TimelineAssetSource = Future<List<BaseAsset>> Function(int index, int count);
|
||||
|
||||
@@ -89,6 +90,8 @@ class TimelineFactory {
|
||||
TimelineService(_timelineRepository.map(userIds, options, groupBy));
|
||||
}
|
||||
|
||||
const _kFirstEmissionAlarmDelay = Duration(seconds: 10);
|
||||
|
||||
class TimelineService {
|
||||
final TimelineAssetSource _assetSource;
|
||||
final TimelineBucketSource _bucketSource;
|
||||
@@ -97,6 +100,8 @@ class TimelineService {
|
||||
int _bufferOffset = 0;
|
||||
List<BaseAsset> _buffer = [];
|
||||
StreamSubscription? _bucketSubscription;
|
||||
Timer? _firstEmissionAlarm;
|
||||
final DateTime _builtAt = DateTime.now();
|
||||
|
||||
int _totalAssets = 0;
|
||||
int get totalAssets => _totalAssets;
|
||||
@@ -105,9 +110,29 @@ class TimelineService {
|
||||
: this._(assetSource: query.assetSource, bucketSource: query.bucketSource, origin: query.origin);
|
||||
|
||||
TimelineService._({required this._assetSource, required this._bucketSource, required this.origin}) {
|
||||
_firstEmissionAlarm = Timer(_kFirstEmissionAlarmDelay, () {
|
||||
Logger('TimelineProbe').severe(
|
||||
'[${origin.name}] no bucket emission ${_kFirstEmissionAlarmDelay.inSeconds}s after service build, '
|
||||
'first fetch looks stuck',
|
||||
);
|
||||
});
|
||||
_bucketSubscription = _bucketSource().listen((buckets) {
|
||||
final alarm = _firstEmissionAlarm;
|
||||
if (alarm != null) {
|
||||
_firstEmissionAlarm = null;
|
||||
if (alarm.isActive) {
|
||||
alarm.cancel();
|
||||
} else {
|
||||
Logger(
|
||||
'TimelineProbe',
|
||||
).severe('[${origin.name}] first bucket emission after ${DateTime.now().difference(_builtAt).inSeconds}s');
|
||||
}
|
||||
}
|
||||
_mutex.run(() async {
|
||||
final totalAssets = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
|
||||
Logger(
|
||||
'TimelineProbe',
|
||||
).fine('[${origin.name}] bucket emission: ${buckets.length} buckets / $totalAssets assets');
|
||||
|
||||
if (totalAssets == 0) {
|
||||
_bufferOffset = 0;
|
||||
@@ -234,6 +259,8 @@ class TimelineService {
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
_firstEmissionAlarm?.cancel();
|
||||
_firstEmissionAlarm = null;
|
||||
await _bucketSubscription?.cancel();
|
||||
_bucketSubscription = null;
|
||||
_buffer = [];
|
||||
|
||||
+14
-5
@@ -58,6 +58,17 @@ void main() async {
|
||||
await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5));
|
||||
await migrateDatabaseIfNeeded(drift);
|
||||
|
||||
final viewSize = WidgetsBinding.instance.platformDispatcher.implicitView?.physicalSize;
|
||||
final launchContext =
|
||||
'prewarm=${Platform.environment['ActivePrewarm'] ?? '0'}, '
|
||||
'view=${viewSize == null ? 'none' : '${viewSize.width}x${viewSize.height}'}, '
|
||||
'lifecycle=${WidgetsBinding.instance.lifecycleState}';
|
||||
if (viewSize == null || viewSize.isEmpty) {
|
||||
Logger('LaunchProbe').warning('launch with zero view bounds: $launchContext');
|
||||
} else {
|
||||
Logger('LaunchProbe').info('launch: $launchContext');
|
||||
}
|
||||
|
||||
runApp(ProviderScope(overrides: [driftProvider.overrideWith(driftOverride(drift))], child: const MainWidget()));
|
||||
} catch (error, stack) {
|
||||
runApp(BootstrapErrorWidget(error: error.toString(), stack: stack.toString()));
|
||||
@@ -123,28 +134,26 @@ class ImmichApp extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserver {
|
||||
final _lifecycleLog = Logger('AppLifeCycle');
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
_lifecycleLog.info(state.name);
|
||||
switch (state) {
|
||||
case AppLifecycleState.resumed:
|
||||
dPrint(() => "[APP STATE] resumed");
|
||||
ref.read(appStateProvider.notifier).handleAppResume();
|
||||
unawaited(ref.read(viewIntentHandlerProvider).onAppResumed());
|
||||
break;
|
||||
case AppLifecycleState.inactive:
|
||||
dPrint(() => "[APP STATE] inactive");
|
||||
ref.read(appStateProvider.notifier).handleAppInactivity();
|
||||
break;
|
||||
case AppLifecycleState.paused:
|
||||
dPrint(() => "[APP STATE] paused");
|
||||
ref.read(appStateProvider.notifier).handleAppPause();
|
||||
break;
|
||||
case AppLifecycleState.detached:
|
||||
dPrint(() => "[APP STATE] detached");
|
||||
ref.read(appStateProvider.notifier).handleAppDetached();
|
||||
break;
|
||||
case AppLifecycleState.hidden:
|
||||
dPrint(() => "[APP STATE] hidden");
|
||||
ref.read(appStateProvider.notifier).handleAppHidden();
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -395,7 +395,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
||||
final currentHeroTag = ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag));
|
||||
_showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
|
||||
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
|
||||
@@ -414,7 +414,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
displayAsset = stackChildren.elementAt(stackIndex);
|
||||
}
|
||||
|
||||
final isCurrent = currentAsset != null && currentAsset.isSameAsset(displayAsset);
|
||||
final isCurrent = currentHeroTag == displayAsset.heroTag;
|
||||
|
||||
final viewportWidth = MediaQuery.widthOf(context);
|
||||
final viewportHeight = MediaQuery.heightOf(context);
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment_builde
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class TimelineArgs {
|
||||
final double maxWidth;
|
||||
@@ -86,13 +87,15 @@ class TimelineStateNotifier extends Notifier<TimelineState> {
|
||||
// This provider watches the buckets from the timeline service & args and serves the segments.
|
||||
// It should be used only after the timeline service and timeline args provider is overridden
|
||||
final timelineSegmentProvider = StreamProvider.autoDispose<List<Segment>>((ref) async* {
|
||||
final args = ref.watch(timelineArgsProvider);
|
||||
final columnCount = args.columnCount;
|
||||
final spacing = args.spacing;
|
||||
final availableTileWidth = args.maxWidth - (spacing * (columnCount - 1));
|
||||
// maxHeight is left out on purpose, a height-only change must not restart the bucket stream
|
||||
final (maxWidth, columnCount, spacing, groupByArg) = ref.watch(
|
||||
timelineArgsProvider.select((args) => (args.maxWidth, args.columnCount, args.spacing, args.groupBy)),
|
||||
);
|
||||
final availableTileWidth = maxWidth - (spacing * (columnCount - 1));
|
||||
final tileExtent = math.max(0, availableTileWidth) / columnCount;
|
||||
Logger('TimelineProbe').info('segments: maxWidth=$maxWidth tileExtent=$tileExtent columns=$columnCount');
|
||||
|
||||
final groupBy = args.groupBy ?? ref.watch(appConfigProvider.select((config) => config.timeline.groupAssetsBy));
|
||||
final groupBy = groupByArg ?? ref.watch(appConfigProvider.select((config) => config.timeline.groupAssetsBy));
|
||||
|
||||
final timelineService = ref.watch(timelineServiceProvider);
|
||||
yield* timelineService.watchBuckets().map((buckets) {
|
||||
|
||||
@@ -28,8 +28,9 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
|
||||
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
|
||||
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class Timeline extends StatelessWidget {
|
||||
class Timeline extends ConsumerWidget {
|
||||
const Timeline({
|
||||
super.key,
|
||||
this.topSliverWidget,
|
||||
@@ -62,35 +63,41 @@ class Timeline extends StatelessWidget {
|
||||
final Widget? loadingWidget;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final columnCount = ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow));
|
||||
return LayoutBuilder(
|
||||
builder: (_, constraints) => ProviderScope(
|
||||
overrides: [
|
||||
timelineArgsProvider.overrideWith(
|
||||
(ref) => TimelineArgs(
|
||||
maxWidth: constraints.maxWidth,
|
||||
maxHeight: constraints.maxHeight,
|
||||
columnCount: ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow)),
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
withStack: withStack,
|
||||
groupBy: groupBy,
|
||||
builder: (_, constraints) {
|
||||
Logger('TimelineProbe').fine('layout pass ${constraints.maxWidth}x${constraints.maxHeight}');
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
// overrideWithValue keeps the scoped args in sync with the latest constraints on rebuilds,
|
||||
// a function override would stay locked to the first frame's constraints for the whole session
|
||||
timelineArgsProvider.overrideWithValue(
|
||||
TimelineArgs(
|
||||
maxWidth: constraints.maxWidth,
|
||||
maxHeight: constraints.maxHeight,
|
||||
columnCount: columnCount,
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
withStack: withStack,
|
||||
groupBy: groupBy,
|
||||
),
|
||||
),
|
||||
if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
|
||||
],
|
||||
child: _SliverTimeline(
|
||||
topSliverWidget: topSliverWidget,
|
||||
topSliverWidgetHeight: topSliverWidgetHeight,
|
||||
bottomSliverWidget: bottomSliverWidget,
|
||||
appBar: appBar,
|
||||
bottomSheet: bottomSheet,
|
||||
withScrubber: withScrubber,
|
||||
persistentBottomBar: persistentBottomBar,
|
||||
snapToMonth: snapToMonth,
|
||||
maxWidth: constraints.maxWidth,
|
||||
loadingWidget: loadingWidget,
|
||||
),
|
||||
if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
|
||||
],
|
||||
child: _SliverTimeline(
|
||||
topSliverWidget: topSliverWidget,
|
||||
topSliverWidgetHeight: topSliverWidgetHeight,
|
||||
bottomSliverWidget: bottomSliverWidget,
|
||||
appBar: appBar,
|
||||
bottomSheet: bottomSheet,
|
||||
withScrubber: withScrubber,
|
||||
persistentBottomBar: persistentBottomBar,
|
||||
snapToMonth: snapToMonth,
|
||||
maxWidth: constraints.maxWidth,
|
||||
loadingWidget: loadingWidget,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -106,6 +113,8 @@ class _AlwaysReadOnlyNotifier extends ReadOnlyModeNotifier {
|
||||
void toggleReadonlyMode() {}
|
||||
}
|
||||
|
||||
const _kZeroWidthAlarmDelay = Duration(seconds: 5);
|
||||
|
||||
class _SliverTimeline extends ConsumerStatefulWidget {
|
||||
const _SliverTimeline({
|
||||
this.topSliverWidget,
|
||||
@@ -149,10 +158,23 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
double _scaleFactor = 3.0;
|
||||
double _baseScaleFactor = 3.0;
|
||||
int? _restoreAssetIndex;
|
||||
Timer? _zeroWidthAlarm;
|
||||
DateTime? _zeroWidthSince;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Logger('TimelineProbe').info('timeline mounted maxWidth=${widget.maxWidth}');
|
||||
if ((widget.maxWidth ?? 0) <= 0) {
|
||||
_zeroWidthSince = DateTime.now();
|
||||
_zeroWidthAlarm = Timer(_kZeroWidthAlarmDelay, () {
|
||||
if (mounted && (widget.maxWidth ?? 0) <= 0) {
|
||||
Logger(
|
||||
'TimelineProbe',
|
||||
).severe('timeline width still 0 ${_kZeroWidthAlarmDelay.inSeconds}s after mount, tiles have no extent');
|
||||
}
|
||||
});
|
||||
}
|
||||
_scrollController = ScrollController(onAttach: _restoreAssetPosition);
|
||||
_eventSubscription = EventStream.shared.listen(_onEvent);
|
||||
|
||||
@@ -168,13 +190,22 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
void didUpdateWidget(covariant _SliverTimeline oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.maxWidth != oldWidget.maxWidth) {
|
||||
final asyncSegments = ref.read(timelineSegmentProvider);
|
||||
asyncSegments.whenData((segments) {
|
||||
final index = _getCurrentAssetIndex(segments);
|
||||
// Refresh to wait for new segments to be generated with the updated width before restoring the scroll position
|
||||
final _ = ref.refresh(timelineArgsProvider);
|
||||
_restoreAssetIndex = index;
|
||||
});
|
||||
Logger('TimelineProbe').info('timeline maxWidth ${oldWidget.maxWidth} -> ${widget.maxWidth}');
|
||||
if ((widget.maxWidth ?? 0) > 0 && _zeroWidthSince != null) {
|
||||
final stuckFor = DateTime.now().difference(_zeroWidthSince!);
|
||||
_zeroWidthAlarm?.cancel();
|
||||
_zeroWidthSince = null;
|
||||
if (stuckFor >= _kZeroWidthAlarmDelay) {
|
||||
Logger(
|
||||
'TimelineProbe',
|
||||
).severe('timeline width recovered to ${widget.maxWidth} after ${stuckFor.inSeconds}s at 0');
|
||||
}
|
||||
}
|
||||
// The updated args already regenerate the segments, only remember the scroll position to restore it afterwards
|
||||
final segments = ref.read(timelineSegmentProvider).valueOrNull;
|
||||
if (segments != null && _scrollController.hasClients) {
|
||||
_restoreAssetIndex = _getCurrentAssetIndex(segments);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,6 +277,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_zeroWidthAlarm?.cancel();
|
||||
_scrollController.dispose();
|
||||
_eventSubscription?.cancel();
|
||||
super.dispose();
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
||||
@@ -17,8 +19,12 @@ final timelineArgsProvider = Provider.autoDispose<TimelineArgs>(
|
||||
final timelineServiceProvider = Provider<TimelineService>(
|
||||
(ref) {
|
||||
final timelineUsers = ref.watch(timelineUsersProvider).valueOrNull ?? [];
|
||||
Logger('TimelineProbe').info('main timeline service built with ${timelineUsers.length} users');
|
||||
final timelineService = ref.watch(timelineFactoryProvider).main(timelineUsers);
|
||||
ref.onDispose(timelineService.dispose);
|
||||
ref.onDispose(() {
|
||||
Logger('TimelineProbe').info('main timeline service disposed');
|
||||
timelineService.dispose();
|
||||
});
|
||||
return timelineService;
|
||||
},
|
||||
// Empty dependencies to inform the framework that this provider
|
||||
@@ -39,5 +45,14 @@ final timelineUsersProvider = StreamProvider<List<String>>((ref) {
|
||||
return Stream.value([]);
|
||||
}
|
||||
|
||||
return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId);
|
||||
// Drift re-emits a fresh but content-identical list on unrelated table updates,
|
||||
// which would dispose and rebuild the timeline service mid-load
|
||||
return ref
|
||||
.watch(timelineRepositoryProvider)
|
||||
.watchTimelineUserIds(currentUserId)
|
||||
.distinct(const ListEquality<String>().equals)
|
||||
.map((users) {
|
||||
Logger('TimelineProbe').info('timeline users emission: ${users.length}');
|
||||
return users;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../unit/factories/local_asset_factory.dart';
|
||||
import '../../unit/factories/remote_asset_factory.dart';
|
||||
|
||||
void main() {
|
||||
group('BaseAsset.isSameAsset', () {
|
||||
test('search/folder copy (localId null) matches the merged DB copy (localId set)', () {
|
||||
// #29472: search and folder assets arrive with localId null, then the viewer
|
||||
// watches the DB copy which fills localId. heroTag embeds localId, so it
|
||||
// diverges for the same asset and isCurrent used to go false.
|
||||
final searchCopy = RemoteAssetFactory.create(id: 'asset-1');
|
||||
final mergedCopy = searchCopy.copyWith(localId: 'local-1');
|
||||
|
||||
expect(searchCopy.localId, isNull);
|
||||
expect(mergedCopy.localId, 'local-1');
|
||||
expect(searchCopy.heroTag, isNot(mergedCopy.heroTag));
|
||||
|
||||
expect(searchCopy.isSameAsset(mergedCopy), isTrue);
|
||||
expect(mergedCopy.isSameAsset(searchCopy), isTrue);
|
||||
});
|
||||
|
||||
test('different remote assets are not the same', () {
|
||||
final a = RemoteAssetFactory.create(id: 'asset-1');
|
||||
final b = RemoteAssetFactory.create(id: 'asset-2');
|
||||
|
||||
expect(a.isSameAsset(b), isFalse);
|
||||
});
|
||||
|
||||
test('same checksum but different remote ids are not the same (duplicate files)', () {
|
||||
final a = RemoteAssetFactory.create(id: 'asset-1');
|
||||
final b = RemoteAssetFactory.create(id: 'asset-2').copyWith(checksum: a.checksum);
|
||||
|
||||
expect(a.checksum, b.checksum);
|
||||
expect(a.isSameAsset(b), isFalse);
|
||||
});
|
||||
|
||||
test('falls back to checksum when only one side has an id (local-only vs remote-only)', () {
|
||||
final remoteOnly = RemoteAssetFactory.create(id: 'asset-1');
|
||||
final localOnly = LocalAssetFactory.create(id: 'local-1').copyWith(checksum: remoteOnly.checksum);
|
||||
|
||||
expect(remoteOnly.isSameAsset(localOnly), isTrue);
|
||||
expect(localOnly.isSameAsset(remoteOnly), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
void main() {
|
||||
late List<LogRecord> records;
|
||||
late StreamSubscription<LogRecord> logSubscription;
|
||||
|
||||
bool isNoEmissionAlarm(LogRecord r) => r.level == Level.SEVERE && r.message.contains('no bucket emission');
|
||||
|
||||
setUp(() {
|
||||
records = [];
|
||||
Logger.root.level = Level.INFO;
|
||||
logSubscription = Logger.root.onRecord.listen(records.add);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
Logger.root.level = Level.OFF;
|
||||
return logSubscription.cancel();
|
||||
});
|
||||
|
||||
test('logs a severe signature when the first bucket emission never arrives', () {
|
||||
fakeAsync((async) {
|
||||
final ctrl = StreamController<List<Bucket>>();
|
||||
final sut = TimelineService((
|
||||
assetSource: (index, count) async => const [],
|
||||
bucketSource: () => ctrl.stream,
|
||||
origin: TimelineOrigin.main,
|
||||
));
|
||||
|
||||
async.elapse(const Duration(seconds: 9));
|
||||
expect(records.where(isNoEmissionAlarm), isEmpty);
|
||||
|
||||
async.elapse(const Duration(seconds: 2));
|
||||
expect(records.where(isNoEmissionAlarm), isNotEmpty);
|
||||
|
||||
sut.dispose();
|
||||
ctrl.close();
|
||||
});
|
||||
});
|
||||
|
||||
test('stays quiet when the first bucket emission arrives in time', () {
|
||||
fakeAsync((async) {
|
||||
final ctrl = StreamController<List<Bucket>>();
|
||||
final sut = TimelineService((
|
||||
assetSource: (index, count) async => const [],
|
||||
bucketSource: () => ctrl.stream,
|
||||
origin: TimelineOrigin.main,
|
||||
));
|
||||
|
||||
ctrl.add(const []);
|
||||
async.elapse(const Duration(seconds: 15));
|
||||
expect(records.where(isNoEmissionAlarm), isEmpty);
|
||||
|
||||
sut.dispose();
|
||||
ctrl.close();
|
||||
});
|
||||
});
|
||||
|
||||
test('stays quiet when the service is disposed before the alarm', () {
|
||||
fakeAsync((async) {
|
||||
final ctrl = StreamController<List<Bucket>>();
|
||||
final sut = TimelineService((
|
||||
assetSource: (index, count) async => const [],
|
||||
bucketSource: () => ctrl.stream,
|
||||
origin: TimelineOrigin.main,
|
||||
));
|
||||
|
||||
async.elapse(const Duration(seconds: 2));
|
||||
sut.dispose();
|
||||
async.elapse(const Duration(seconds: 15));
|
||||
expect(records.where(isNoEmissionAlarm), isEmpty);
|
||||
|
||||
ctrl.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import 'dart:async';
|
||||
|
||||
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/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
// A first fetch that never delivers - the state a suspended or storm-starved
|
||||
// bucket watch is stuck in when the timeline mounts on a zero-sized first frame
|
||||
class _FrozenBucketService implements TimelineService {
|
||||
final _ctrl = StreamController<List<Bucket>>.broadcast();
|
||||
|
||||
@override
|
||||
Stream<List<Bucket>> Function() get watchBuckets =>
|
||||
() => _ctrl.stream;
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _EmptyBucketService implements TimelineService {
|
||||
@override
|
||||
Stream<List<Bucket>> Function() get watchBuckets =>
|
||||
() => Stream.value(const []);
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('timeline args follow constraints after a zero-sized first frame while buckets are still loading', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = Size.zero;
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
TimelineArgs? probed;
|
||||
final probe = Consumer(
|
||||
builder: (_, ref, __) {
|
||||
probed = ref.watch(timelineArgsProvider);
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
timelineServiceProvider.overrideWithValue(_FrozenBucketService()),
|
||||
appConfigProvider.overrideWithValue(const AppConfig()),
|
||||
],
|
||||
child: MaterialApp(home: Timeline(withScrubber: false, readOnly: true, loadingWidget: probe)),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(probed, isNotNull);
|
||||
expect(probed!.maxWidth, 0.0);
|
||||
|
||||
tester.view.physicalSize = const Size(1206, 2622);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
probed!.maxWidth,
|
||||
402.0,
|
||||
reason: 'args locked to the zero-sized first frame leave the timeline blank for the whole session',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('timeline args follow constraints after a zero-sized first frame once buckets resolve', (tester) async {
|
||||
tester.view.physicalSize = Size.zero;
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
TimelineArgs? probed;
|
||||
final probe = SliverToBoxAdapter(
|
||||
child: Consumer(
|
||||
builder: (_, ref, __) {
|
||||
probed = ref.watch(timelineArgsProvider);
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
timelineServiceProvider.overrideWithValue(_EmptyBucketService()),
|
||||
appConfigProvider.overrideWithValue(const AppConfig()),
|
||||
],
|
||||
child: MaterialApp(
|
||||
home: Timeline(
|
||||
withScrubber: false,
|
||||
readOnly: true,
|
||||
appBar: const SliverToBoxAdapter(child: SizedBox.shrink()),
|
||||
topSliverWidget: probe,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
tester.view.physicalSize = const Size(1206, 2622);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(probed, isNotNull);
|
||||
expect(probed!.maxWidth, 402.0);
|
||||
});
|
||||
|
||||
testWidgets('a timeline stuck at zero width logs a severe blank timeline signature after 5s', (tester) async {
|
||||
final records = <LogRecord>[];
|
||||
Logger.root.level = Level.INFO;
|
||||
addTearDown(() => Logger.root.level = Level.OFF);
|
||||
final logSubscription = Logger.root.onRecord.listen(records.add);
|
||||
addTearDown(logSubscription.cancel);
|
||||
|
||||
bool isZeroWidthAlarm(LogRecord r) => r.level == Level.SEVERE && r.message.contains('width still 0');
|
||||
|
||||
tester.view.physicalSize = Size.zero;
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
timelineServiceProvider.overrideWithValue(_FrozenBucketService()),
|
||||
appConfigProvider.overrideWithValue(const AppConfig()),
|
||||
],
|
||||
child: const MaterialApp(home: Timeline(withScrubber: false, readOnly: true)),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
expect(records.where(isZeroWidthAlarm), isEmpty);
|
||||
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
expect(records.where(isZeroWidthAlarm), isNotEmpty);
|
||||
});
|
||||
}
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
FROM ghcr.io/immich-app/base-server-dev:202606161235@sha256:9f88b07acc8b7bf37a1dd3d5a19193f664443eaaab4e08e9f9341414c5e4b23f AS builder
|
||||
FROM ghcr.io/immich-app/base-server-dev:202607061334@sha256:c83ef9c6d7f5e7f6a276fe96647484d46b7bf6cacc1e35b823dd1d7a1aeda3b8 AS builder
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
CI=1 \
|
||||
COREPACK_HOME=/tmp \
|
||||
@@ -80,7 +80,7 @@ RUN --mount=type=cache,id=pnpm-packages,target=/buildcache/pnpm-store \
|
||||
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
|
||||
mise //:plugins
|
||||
|
||||
FROM ghcr.io/immich-app/base-server-prod:202606161235@sha256:c6d59e3923f548d29a212b4dc51b6281a722cfa1da7972a009c0f3830f5762d6
|
||||
FROM ghcr.io/immich-app/base-server-prod:202607061334@sha256:c07ef6510ff2ccb6ead28434ade7c7a0c08aef33d7d3466b14beccdd431e86f7
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
ENV NODE_ENV=production \
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# dev build
|
||||
FROM ghcr.io/immich-app/base-server-dev:202606161235@sha256:9f88b07acc8b7bf37a1dd3d5a19193f664443eaaab4e08e9f9341414c5e4b23f AS dev
|
||||
FROM ghcr.io/immich-app/base-server-dev:202607061334@sha256:c83ef9c6d7f5e7f6a276fe96647484d46b7bf6cacc1e35b823dd1d7a1aeda3b8 AS dev
|
||||
|
||||
|
||||
COPY --from=ghcr.io/jdx/mise:2026.6.14@sha256:b8f8c20fc3308f8b1d00ccca2bc968e4e208af1c5c1069e1ad9753baa099acff /usr/local/bin/mise /usr/local/bin/mise
|
||||
|
||||
Reference in New Issue
Block a user