Compare commits

..

7 Commits

Author SHA1 Message Date
Daniel Dietzler 7a9b9e01be feat: user agnostic database restore (#29512) 2026-07-07 21:16:48 +02:00
Daniel Dietzler 0cbe07c4d4 fix: integrity checks dto validation (#29621) 2026-07-07 20:49:26 +02:00
renovate[bot] 45ea248e8b chore(deps): update github-actions (#29677) 2026-07-07 19:56:57 +02:00
Adam Gastineau 8cbf0db3b3 fix(mobile): disable broken Xcode queue debugging (#29657) 2026-07-07 12:26:19 -05:00
Adam Gastineau a225c962de fix(mobile): properly handle live photos locally (#29462)
* fix(mobile): properly handle live photos locally

* Added basic motion photo data tests

* Cover mismatched platforms and motion photos

* Revert unnecessary storage.repository changes

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-07-07 12:25:43 -05:00
Mees Frensel daabab8c1c fix(docs): update developer setup (#29661) 2026-07-07 17:07:35 +00:00
Brandon Wees a4f28dafb2 fix(mobile): missing crop options and rotation scaling (#29645)
* fix: missing aspect ratio options in mobile editor

* fix: smooth rotation

* chore: comments

* chore: better preset management and tooling

* lint

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-07-07 12:05:50 -05:00
28 changed files with 374 additions and 459 deletions
+4 -4
View File
@@ -78,10 +78,10 @@ jobs:
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Login to GitHub Container Registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
registry: ghcr.io
@@ -96,7 +96,7 @@ jobs:
- name: Generate docker image tags
id: metadata
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
flavor: |
latest=false
@@ -107,7 +107,7 @@ jobs:
type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }}
- name: Build and push image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
file: packages/cli/Dockerfile
platforms: linux/amd64,linux/arm64
+3 -3
View File
@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/autobuild@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
with:
category: '/language:${{matrix.language}}'
+2 -2
View File
@@ -60,7 +60,7 @@ jobs:
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -90,7 +90,7 @@ jobs:
suffix: ['']
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
+3 -3
View File
@@ -30,7 +30,7 @@ This environment includes the services below. Additional details are available i
- Redis
- PostgreSQL development database with exposed port `5432` so you can use any database client to access it
All the services are packaged to run as with single Docker Compose command.
All the services are packaged to run with a single Docker Compose command.
:::tip mise
[mise](https://mise.jdx.dev) is used throughout the project to manage tool versions and run tasks. [Install mise](https://mise.jdx.dev/installing-mise.html), then from the repo root run `mise trust` and `mise install` to get all required tools. Tasks for each service can be run from the repo root using `mise //namespace:task` (e.g. `mise //server:lint`). To list all available tasks, run `mise tasks ls --all`.
@@ -41,7 +41,7 @@ All the services are packaged to run as with single Docker Compose command.
1. Clone the project repo.
2. Run `cp docker/example.env docker/.env`.
3. Edit `docker/.env` to provide values for the required variable `UPLOAD_LOCATION`.
4. Install dependencies - `pnpm i`
4. Install dependencies - `mise x -- pnpm i`
5. From the root directory, run:
```bash title="Start development server"
@@ -52,7 +52,7 @@ mise dev
All the services will be started with hot-reloading enabled for a quick feedback loop.
You can access the web from `http://your-machine-ip:3000` or `http://localhost:3000` and access the server from the mobile app at `http://your-machine-ip:3000/api`
You can access the web from `http://your-machine-ip:3000` or `http://localhost:3000` and access the server from the mobile app at `http://your-machine-ip:3000`
**Notes:**
@@ -70,7 +70,8 @@
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
allowLocationSimulation = "YES"
queueDebuggingEnabled = "NO">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
+60 -14
View File
@@ -1,19 +1,65 @@
import 'package:flutter/material.dart';
enum AspectRatioPreset {
free(ratio: null, label: 'Free', icon: Icons.crop_free_rounded),
square(ratio: 1.0, label: '1:1', icon: Icons.crop_square_rounded),
ratio16x9(ratio: 16 / 9, label: '16:9', icon: Icons.crop_16_9_rounded),
ratio3x2(ratio: 3 / 2, label: '3:2', icon: Icons.crop_3_2_rounded),
ratio7x5(ratio: 7 / 5, label: '7:5', icon: Icons.crop_7_5_rounded),
ratio9x16(ratio: 9 / 16, label: '9:16', icon: Icons.crop_16_9_rounded, iconRotated: true),
ratio2x3(ratio: 2 / 3, label: '2:3', icon: Icons.crop_3_2_rounded, iconRotated: true),
ratio5x7(ratio: 5 / 7, label: '5:7', icon: Icons.crop_7_5_rounded, iconRotated: true);
class CropAspectRatio {
final int? numerator;
final int? denominator;
final double? ratio;
final String label;
final IconData icon;
final bool iconRotated;
final String? customLabel;
final IconData? icon;
const AspectRatioPreset({required this.ratio, required this.label, required this.icon, this.iconRotated = false});
const CropAspectRatio({this.numerator, this.denominator, this.customLabel, this.icon});
static const free = CropAspectRatio(customLabel: "Free", icon: Icons.crop_free);
static const original = CropAspectRatio(customLabel: "Original", icon: Icons.crop_original);
String get label {
return customLabel ?? (numerator != null && denominator != null ? '$numerator:$denominator' : 'Free');
}
bool get hasFlippedVariant => numerator != denominator;
double? get ratio => (numerator != null && denominator != null) ? numerator! / denominator! : null;
CropAspectRatio get flipped {
return CropAspectRatio(numerator: denominator, denominator: numerator, customLabel: customLabel, icon: icon);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is CropAspectRatio &&
other.numerator == numerator &&
other.denominator == denominator &&
other.customLabel == customLabel &&
other.icon == icon;
}
@override
int get hashCode {
return numerator.hashCode ^ denominator.hashCode ^ customLabel.hashCode ^ icon.hashCode;
}
}
const aspectRatioFree = CropAspectRatio(customLabel: "Free", icon: Icons.crop_free);
const aspectRatioOriginal = CropAspectRatio(customLabel: "Original", icon: Icons.crop_original);
final aspectRatioPresets = [
CropAspectRatio.free,
CropAspectRatio.original,
const CropAspectRatio(numerator: 1, denominator: 1),
// lanscape
const CropAspectRatio(numerator: 16, denominator: 9),
const CropAspectRatio(numerator: 3, denominator: 2),
const CropAspectRatio(numerator: 7, denominator: 5),
const CropAspectRatio(numerator: 4, denominator: 3),
// portrait
const CropAspectRatio(numerator: 16, denominator: 9).flipped,
const CropAspectRatio(numerator: 3, denominator: 2).flipped,
const CropAspectRatio(numerator: 7, denominator: 5).flipped,
const CropAspectRatio(numerator: 4, denominator: 3).flipped,
];
@@ -27,7 +27,6 @@ sealed class BaseAsset {
final int? height;
final int? durationMs;
final bool isFavorite;
final String? livePhotoVideoId;
final bool isEdited;
const BaseAsset({
@@ -40,32 +39,15 @@ sealed class BaseAsset {
this.height,
this.durationMs,
this.isFavorite = false,
this.livePhotoVideoId,
required this.isEdited,
});
bool get isImage => type == AssetType.image;
bool get isVideo => type == AssetType.video;
bool get isMotionPhoto => livePhotoVideoId != null;
bool get isMotionPhoto => playbackStyle == AssetPlaybackStyle.livePhoto;
bool get isAnimatedImage => playbackStyle == AssetPlaybackStyle.imageAnimated;
AssetPlaybackStyle get playbackStyle {
if (isVideo) {
return AssetPlaybackStyle.video;
}
if (isMotionPhoto) {
return AssetPlaybackStyle.livePhoto;
}
if (isImage && durationMs != null && durationMs! > 0) {
return AssetPlaybackStyle.imageAnimated;
}
if (isImage) {
return AssetPlaybackStyle.image;
}
return AssetPlaybackStyle.unknown;
}
Duration get duration {
final durationMs = this.durationMs;
if (durationMs != null) {
@@ -86,6 +68,7 @@ sealed class BaseAsset {
String? get localId;
String? get remoteId;
String get heroTag;
AssetPlaybackStyle get playbackStyle;
@override
String toString() {
@@ -25,7 +25,6 @@ class LocalAsset extends BaseAsset {
super.height,
super.durationMs,
super.isFavorite = false,
super.livePhotoVideoId,
this.orientation = 0,
required this.playbackStyle,
this.adjustmentTime,
@@ -10,6 +10,7 @@ class RemoteAsset extends BaseAsset {
final AssetVisibility visibility;
final String ownerId;
final String? stackId;
final String? livePhotoVideoId;
final DateTime? uploadedAt;
final DateTime? deletedAt;
@@ -29,7 +30,7 @@ class RemoteAsset extends BaseAsset {
super.isFavorite = false,
this.thumbHash,
this.visibility = AssetVisibility.timeline,
super.livePhotoVideoId,
this.livePhotoVideoId,
this.stackId,
required super.isEdited,
this.deletedAt,
@@ -38,6 +39,23 @@ class RemoteAsset extends BaseAsset {
@override
String? get localId => localAssetId;
@override
AssetPlaybackStyle get playbackStyle {
if (isVideo) {
return AssetPlaybackStyle.video;
}
if (livePhotoVideoId != null) {
return AssetPlaybackStyle.livePhoto;
}
if (isImage && durationMs != null && durationMs! > 0) {
return AssetPlaybackStyle.imageAnimated;
}
if (isImage) {
return AssetPlaybackStyle.image;
}
return AssetPlaybackStyle.unknown;
}
@override
String? get remoteId => id;
@@ -90,6 +108,7 @@ class RemoteAsset extends BaseAsset {
thumbHash == other.thumbHash &&
visibility == other.visibility &&
stackId == other.stackId &&
livePhotoVideoId == other.livePhotoVideoId &&
uploadedAt == other.uploadedAt &&
deletedAt == other.deletedAt;
}
@@ -103,6 +122,7 @@ class RemoteAsset extends BaseAsset {
thumbHash.hashCode ^
visibility.hashCode ^
stackId.hashCode ^
livePhotoVideoId.hashCode ^
uploadedAt.hashCode ^
deletedAt.hashCode;
@@ -26,6 +26,10 @@ class AssetService {
return _localRepository.getByChecksum(checksum);
}
Future<LocalAsset?> getLocalAsset(String id) {
return _localRepository.get(id);
}
Future<RemoteAsset?> getRemoteAssetByChecksum(String checksum) {
return _remoteRepository.getByChecksum(checksum);
}
@@ -10,7 +10,6 @@ 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);
@@ -90,8 +89,6 @@ class TimelineFactory {
TimelineService(_timelineRepository.map(userIds, options, groupBy));
}
const _kFirstEmissionAlarmDelay = Duration(seconds: 10);
class TimelineService {
final TimelineAssetSource _assetSource;
final TimelineBucketSource _bucketSource;
@@ -100,8 +97,6 @@ class TimelineService {
int _bufferOffset = 0;
List<BaseAsset> _buffer = [];
StreamSubscription? _bucketSubscription;
Timer? _firstEmissionAlarm;
final DateTime _builtAt = DateTime.now();
int _totalAssets = 0;
int get totalAssets => _totalAssets;
@@ -110,29 +105,9 @@ 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;
@@ -259,8 +234,6 @@ class TimelineService {
}
Future<void> dispose() async {
_firstEmissionAlarm?.cancel();
_firstEmissionAlarm = null;
await _bucketSubscription?.cancel();
_bucketSubscription = null;
_buffer = [];
@@ -33,6 +33,7 @@ class StorageRepository {
return file;
}
// TODO(agg23): Unify these methods
Future<File?> getMotionFileForAsset(LocalAsset asset) async {
File? file;
final log = Logger('StorageRepository');
+5 -14
View File
@@ -58,17 +58,6 @@ 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()));
@@ -134,26 +123,28 @@ 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;
}
@@ -114,7 +114,6 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
_PropertyItem(label: 'Height', value: asset.height?.toString()),
_PropertyItem(label: 'Duration', value: asset.durationMs != null ? '${asset.durationMs} ms' : null),
_PropertyItem(label: 'Is Favorite', value: asset.isFavorite.toString()),
_PropertyItem(label: 'Live Photo Video ID', value: asset.livePhotoVideoId),
_PropertyItem(label: 'Is Edited', value: asset.isEdited.toString()),
]);
}
@@ -151,6 +150,7 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
_PropertyItem(label: 'Thumb Hash', value: asset.thumbHash),
_PropertyItem(label: 'Visibility', value: asset.visibility.toString()),
_PropertyItem(label: 'Stack ID', value: asset.stackId),
_PropertyItem(label: 'Live Photo Video ID', value: asset.livePhotoVideoId),
];
properties.insertAll(4, additionalProps);
@@ -154,7 +154,7 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
}
class _AspectRatioButton extends StatelessWidget {
final AspectRatioPreset ratio;
final CropAspectRatio ratio;
final bool isSelected;
final VoidCallback onPressed;
@@ -162,15 +162,16 @@ class _AspectRatioButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final color = isSelected ? context.primaryColor : context.themeData.iconTheme.color;
return Column(
mainAxisSize: MainAxisSize.max,
children: [
IconButton(
iconSize: 36,
icon: Transform.rotate(
angle: ratio.iconRotated ? pi / 2 : 0,
child: Icon(ratio.icon, color: isSelected ? context.primaryColor : context.themeData.iconTheme.color),
),
icon: ratio.ratio != null
? _AspectRatioRect(ratio: ratio.ratio!, color: color)
: Icon(ratio.icon, color: color),
onPressed: onPressed,
),
Text(ratio.label, style: context.textTheme.displayMedium),
@@ -179,6 +180,32 @@ class _AspectRatioButton extends StatelessWidget {
}
}
class _AspectRatioRect extends StatelessWidget {
final double ratio;
final Color? color;
const _AspectRatioRect({required this.ratio, required this.color});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 28,
height: 28,
child: Center(
child: AspectRatio(
aspectRatio: ratio,
child: Container(
decoration: BoxDecoration(
border: Border.all(color: color ?? Colors.transparent, width: 3),
borderRadius: const BorderRadius.all(Radius.circular(4)),
),
),
),
),
);
}
}
class _AspectRatioSelector extends ConsumerWidget {
const _AspectRatioSelector();
@@ -187,22 +214,16 @@ class _AspectRatioSelector extends ConsumerWidget {
final editorState = ref.watch(editorStateProvider);
final editorNotifier = ref.read(editorStateProvider.notifier);
// the whole crop view is rotated, so we need to swap the aspect ratio when the rotation is 90 or 270 degrees
double? selectedAspectRatio = editorState.aspectRatio;
if (editorState.rotationAngle % 180 != 0 && selectedAspectRatio != null) {
selectedAspectRatio = 1 / selectedAspectRatio;
}
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: AspectRatioPreset.values.map((entry) {
children: aspectRatioPresets.map((entry) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: _AspectRatioButton(
ratio: entry,
isSelected: selectedAspectRatio == entry.ratio,
onPressed: () => editorNotifier.setAspectRatio(entry.ratio),
isSelected: editorState.aspectRatio == entry,
onPressed: () => editorNotifier.setAspectRatio(entry),
),
);
}).toList(),
@@ -357,8 +378,22 @@ class _EditorPreviewState extends ConsumerState<_EditorPreview> with TickerProvi
final editorState = ref.watch(editorStateProvider);
final editorNotifier = ref.read(editorStateProvider.notifier);
ref.listen(editorStateProvider, (_, current) {
cropController.aspectRatio = current.aspectRatio;
ref.listen(editorStateProvider, (previous, current) {
// Only re-apply the aspect ratio when it changes, otherwise the crop rect will shrink on every rotation
if (previous?.aspectRatio != current.aspectRatio) {
double? ratio;
ratio = switch (current.aspectRatio) {
CropAspectRatio.original => current.originalWidth / current.originalHeight,
_ => current.aspectRatio.ratio,
};
if (current.rotationAngle % 180 != 0) {
ratio = ratio != null ? 1 / ratio : null;
}
cropController.aspectRatio = ratio;
}
if (cropController.crop != current.crop) {
cropController.crop = current.crop;
@@ -386,7 +421,9 @@ class _EditorPreviewState extends ConsumerState<_EditorPreview> with TickerProvi
1.0,
1.0,
),
child: Container(
child: AnimatedContainer(
duration: editorState.animationDuration,
curve: Curves.easeInOut,
padding: const EdgeInsets.all(10),
width: (editorState.rotationAngle % 180 == 0) ? baseWidth : baseHeight,
height: (editorState.rotationAngle % 180 == 0) ? baseHeight : baseWidth,
@@ -1,5 +1,6 @@
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/aspect_ratios.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/utils/editor.utils.dart';
@@ -60,13 +61,8 @@ class EditorProvider extends Notifier<EditorState> {
state = state.copyWith(crop: crop, hasUnsavedEdits: true);
}
void setAspectRatio(double? aspectRatio) {
if (aspectRatio != null && state.rotationAngle % 180 != 0) {
// When rotated 90 or 270 degrees, swap width and height for aspect ratio calculations
aspectRatio = 1 / aspectRatio;
}
state = state.copyWith(aspectRatio: aspectRatio);
void setAspectRatio(CropAspectRatio preset) {
state = state.copyWith(aspectRatio: preset, hasUnsavedEdits: true);
}
void resetEdits() {
@@ -76,19 +72,19 @@ class EditorProvider extends Notifier<EditorState> {
flipHorizontal: false,
flipVertical: false,
crop: const Rect.fromLTRB(0, 0, 1, 1),
aspectRatio: null,
aspectRatio: CropAspectRatio.free,
hasUnsavedEdits: true,
);
}
void rotateCCW() {
_animateRotation(state.rotationAngle - 90);
state = state.copyWith(hasUnsavedEdits: true);
state = state.copyWith(aspectRatio: state.aspectRatio.flipped, hasUnsavedEdits: true);
}
void rotateCW() {
_animateRotation(state.rotationAngle + 90);
state = state.copyWith(hasUnsavedEdits: true);
state = state.copyWith(aspectRatio: state.aspectRatio.flipped, hasUnsavedEdits: true);
}
void flipHorizontally() {
@@ -117,7 +113,7 @@ class EditorState {
final bool flipHorizontal;
final bool flipVertical;
final Rect crop;
final double? aspectRatio;
final CropAspectRatio aspectRatio;
final int originalWidth;
final int originalHeight;
@@ -132,7 +128,7 @@ class EditorState {
bool? flipHorizontal,
bool? flipVertical,
Rect? crop,
this.aspectRatio,
CropAspectRatio? aspectRatio,
int? originalWidth,
int? originalHeight,
Duration? animationDuration,
@@ -145,6 +141,7 @@ class EditorState {
originalWidth = originalWidth ?? 0,
originalHeight = originalHeight ?? 0,
crop = crop ?? const Rect.fromLTRB(0, 0, 1, 1),
aspectRatio = aspectRatio ?? CropAspectRatio.free,
hasUnsavedEdits = hasUnsavedEdits ?? false;
EditorState copyWith({
@@ -152,7 +149,7 @@ class EditorState {
int? rotationAngle,
bool? flipHorizontal,
bool? flipVertical,
double? aspectRatio = double.infinity,
CropAspectRatio? aspectRatio,
int? originalWidth,
int? originalHeight,
Duration? animationDuration,
@@ -164,7 +161,7 @@ class EditorState {
rotationAngle: rotationAngle ?? this.rotationAngle,
flipHorizontal: flipHorizontal ?? this.flipHorizontal,
flipVertical: flipVertical ?? this.flipVertical,
aspectRatio: aspectRatio == double.infinity ? this.aspectRatio : aspectRatio,
aspectRatio: aspectRatio ?? this.aspectRatio,
animationDuration: animationDuration ?? this.animationDuration,
originalWidth: originalWidth ?? this.originalWidth,
originalHeight: originalHeight ?? this.originalHeight,
@@ -122,9 +122,14 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
);
}
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
final file = await StorageRepository().getFileForAsset(id);
// Attempt to retrieve LocalAsset, falling back to remote if it cannot be found
final localAsset = await _localPlaybackAsset(videoAsset);
if (localAsset != null) {
final file = localAsset.isMotionPhoto
? await StorageRepository().getMotionFileForAsset(localAsset)
: await StorageRepository().getFileForAsset(localAsset.id);
if (!mounted) {
return null;
}
@@ -141,14 +146,13 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
);
}
final remoteId = (videoAsset as RemoteAsset).id;
final remoteAsset = videoAsset as RemoteAsset;
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final isOriginalVideo = ref.read(appConfigProvider).viewer.loadOriginalVideo;
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
final String videoUrl = videoAsset.livePhotoVideoId != null
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
final String assetId = remoteAsset.livePhotoVideoId ?? remoteAsset.id;
final String videoUrl = '$serverEndpoint/assets/$assetId/$postfixUrl';
return VideoSource.init(path: videoUrl, type: VideoSourceType.network, headers: ApiService.getRequestHeaders());
} catch (error) {
@@ -157,6 +161,43 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
}
}
Future<LocalAsset?> _localPlaybackAsset(BaseAsset baseAsset) async {
if (!baseAsset.hasLocal) {
return null;
}
LocalAsset? localAsset;
if (baseAsset is LocalAsset) {
localAsset = baseAsset;
} else {
final localId = (baseAsset as RemoteAsset).localId;
localAsset = localId != null ? await ref.read(assetServiceProvider).getLocalAsset(localId) : null;
}
if (localAsset == null) {
_log.severe(
'Invariant violation: asset ${baseAsset.name} (${baseAsset.localId}) is marked `hasLocal` but local asset could not be retrieved',
);
return null;
}
// Clients (local) may not correctly recognize a given asset as a motion photo. This allows for a scenario where both remote and local
// have the same asset (hash), but only the remote properly recognizes it as a motion asset
// If this scenario occurs, fall back to using the remote asset
if (baseAsset.isMotionPhoto && !localAsset.isMotionPhoto) {
// Platform mismatch for motion photo, use remote instead
_log.warning(
'Mismatched local and remote motion states on ${baseAsset.name} (${baseAsset.localId}), local = ${localAsset.isMotionPhoto}, remote = ${baseAsset.isMotionPhoto}',
);
return null;
}
return localAsset;
}
void _onPlaybackReady() async {
if (!mounted || !widget.isCurrent) {
return;
@@ -297,8 +297,7 @@ class _AssetTypeIcons extends StatelessWidget {
@override
Widget build(BuildContext context) {
final remoteAsset = asset is RemoteAsset ? asset as RemoteAsset : null;
final isLivePhoto = remoteAsset?.livePhotoVideoId != null;
final isLivePhoto = asset.isMotionPhoto;
return Column(
mainAxisSize: MainAxisSize.min,
@@ -7,7 +7,6 @@ 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;
@@ -87,15 +86,13 @@ 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* {
// 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 args = ref.watch(timelineArgsProvider);
final columnCount = args.columnCount;
final spacing = args.spacing;
final availableTileWidth = args.maxWidth - (spacing * (columnCount - 1));
final tileExtent = math.max(0, availableTileWidth) / columnCount;
Logger('TimelineProbe').info('segments: maxWidth=$maxWidth tileExtent=$tileExtent columns=$columnCount');
final groupBy = groupByArg ?? ref.watch(appConfigProvider.select((config) => config.timeline.groupAssetsBy));
final groupBy = args.groupBy ?? ref.watch(appConfigProvider.select((config) => config.timeline.groupAssetsBy));
final timelineService = ref.watch(timelineServiceProvider);
yield* timelineService.watchBuckets().map((buckets) {
@@ -28,9 +28,8 @@ 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 ConsumerWidget {
class Timeline extends StatelessWidget {
const Timeline({
super.key,
this.topSliverWidget,
@@ -63,41 +62,35 @@ class Timeline extends ConsumerWidget {
final Widget? loadingWidget;
@override
Widget build(BuildContext context, WidgetRef ref) {
final columnCount = ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow));
Widget build(BuildContext context) {
return LayoutBuilder(
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,
),
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,
),
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,
),
),
);
}
}
@@ -113,8 +106,6 @@ class _AlwaysReadOnlyNotifier extends ReadOnlyModeNotifier {
void toggleReadonlyMode() {}
}
const _kZeroWidthAlarmDelay = Duration(seconds: 5);
class _SliverTimeline extends ConsumerStatefulWidget {
const _SliverTimeline({
this.topSliverWidget,
@@ -158,24 +149,11 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
double _scaleFactor = 3.0;
double _baseScaleFactor = 3.0;
int? _restoreAssetIndex;
Timer? _zeroWidthAlarm;
DateTime? _zeroWidthSince;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
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);
@@ -191,22 +169,13 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
void didUpdateWidget(covariant _SliverTimeline oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.maxWidth != oldWidget.maxWidth) {
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);
}
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;
});
}
}
@@ -276,7 +245,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_zeroWidthAlarm?.cancel();
_scrollController.dispose();
_eventSubscription?.cancel();
super.dispose();
@@ -1,6 +1,4 @@
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';
@@ -19,12 +17,8 @@ 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(() {
Logger('TimelineProbe').info('main timeline service disposed');
timelineService.dispose();
});
ref.onDispose(timelineService.dispose);
return timelineService;
},
// Empty dependencies to inform the framework that this provider
@@ -45,14 +39,5 @@ final timelineUsersProvider = StreamProvider<List<String>>((ref) {
return Stream.value([]);
}
// 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;
});
return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId);
});
@@ -28,8 +28,8 @@ class SystemConfigIntegrityChecksumJob {
/// Percentage limit of the integrity checksum job
///
/// Minimum value: 0
/// Maximum value: 9007199254740991
int percentageLimit;
/// Maximum value: 1
double percentageLimit;
/// How long the integrity checksum job may run for
///
@@ -75,7 +75,7 @@ class SystemConfigIntegrityChecksumJob {
return SystemConfigIntegrityChecksumJob(
cronExpression: mapValueOfType<String>(json, r'cronExpression')!,
enabled: mapValueOfType<bool>(json, r'enabled')!,
percentageLimit: mapValueOfType<int>(json, r'percentageLimit')!,
percentageLimit: mapValueOfType<double>(json, r'percentageLimit')!,
timeLimit: mapValueOfType<int>(json, r'timeLimit')!,
);
}
@@ -1,81 +0,0 @@
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();
});
});
}
@@ -70,4 +70,36 @@ void main() {
expect((assets.first as RemoteAsset).id, asset.id);
});
});
group('live photos', () {
test('remote-only live photo contains livePhotoVideoId and is marked as a motion photo', () async {
final user = await ctx.newUser();
final asset = await ctx.newRemoteAsset(ownerId: user.id, livePhotoVideoId: 'motion-photo-1');
final assets = await sut.main([user.id], .day).assetSource(0, 10);
expect(assets, hasLength(1));
final remote = assets.single as RemoteAsset;
expect(remote.id, asset.id);
expect(remote.livePhotoVideoId, 'motion-photo-1');
expect(remote.isMotionPhoto, isTrue);
expect(remote.localId, isNull);
});
test('merged live photo resolves localId and is marked as a motion photo', () async {
final user = await ctx.newUser();
const checksum = 'shared-live-photo-checksum';
final asset = await ctx.newRemoteAsset(ownerId: user.id, checksum: checksum, livePhotoVideoId: 'motion-photo-2');
final local = await ctx.newLocalAsset(checksum: checksum);
final assets = await sut.main([user.id], .day).assetSource(0, 10);
expect(assets, hasLength(1));
final remote = assets.single as RemoteAsset;
expect(remote.id, asset.id);
expect(remote.livePhotoVideoId, 'motion-photo-2');
expect(remote.isMotionPhoto, isTrue);
expect(remote.localId, local.id);
});
});
}
@@ -1,150 +0,0 @@
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);
});
}
+3 -2
View File
@@ -25917,9 +25917,10 @@
},
"percentageLimit": {
"description": "Percentage limit of the integrity checksum job",
"maximum": 9007199254740991,
"format": "double",
"maximum": 1,
"minimum": 0,
"type": "integer"
"type": "number"
},
"timeLimit": {
"description": "How long the integrity checksum job may run for",
+6 -1
View File
@@ -74,7 +74,12 @@ const SystemConfigIntegrityJobSchema = z
const SystemConfigIntegrityChecksumJobSchema = SystemConfigIntegrityJobSchema.extend({
timeLimit: z.int().nonnegative().describe('How long the integrity checksum job may run for'),
percentageLimit: z.int().nonnegative().describe('Percentage limit of the integrity checksum job'),
percentageLimit: z
.float32()
.nonnegative()
.max(1)
.describe('Percentage limit of the integrity checksum job')
.meta({ format: 'double' }),
})
.describe('Integrity checksum job config')
.meta({ id: 'SystemConfigIntegrityChecksumJob' });
+67 -1
View File
@@ -407,7 +407,7 @@ export class DatabaseBackupService {
progressCb?.('restore', progress);
});
await pipeline(sqlStream, progressSource, psql, progressSink);
await pipeline(sqlStream, createSqlOwnerTransformStream(databaseUsername), progressSource, psql, progressSink);
try {
progressCb?.('migrations', 0.9);
@@ -572,3 +572,69 @@ function createSqlProgressStreams(cb: (progress: number) => void) {
return [source, sink];
}
function createSqlOwnerTransformStream(databaseUsername: string) {
const OWNER_MARKER_START = new TextEncoder().encode('OWNER TO ');
const DATA_MARKER_START = new TextEncoder().encode('FROM stdin');
const LINE_END = new TextEncoder().encode(';');
const owner = new TextEncoder().encode(databaseUsername);
let ownerSequenceIndex = 0;
let replacingOwnerIndex = 0;
let replacingOwner = false;
let readingDataIndex = 0;
let dataPart = false;
return new PassThrough({
transform(chunk, _encoding, callback) {
let result = chunk;
if (!dataPart) {
for (let index = 0; index < result.length; index++) {
if (replacingOwner) {
if (result[index] === LINE_END[0]) {
result = Buffer.concat([result.slice(0, index), owner.slice(replacingOwnerIndex), result.slice(index)]);
replacingOwnerIndex = owner.length;
} else {
result[index] = owner[replacingOwnerIndex];
replacingOwnerIndex++;
}
}
if (replacingOwnerIndex === owner.length) {
replacingOwner = false;
}
if (result[index] === OWNER_MARKER_START[ownerSequenceIndex]) {
ownerSequenceIndex++;
} else {
ownerSequenceIndex = 0;
}
if (ownerSequenceIndex === OWNER_MARKER_START.length) {
ownerSequenceIndex = 0;
replacingOwner = true;
replacingOwnerIndex = 0;
}
if (result[index] === DATA_MARKER_START[readingDataIndex]) {
readingDataIndex++;
} else {
readingDataIndex = 0;
}
if (readingDataIndex === DATA_MARKER_START.length) {
dataPart = true;
break;
}
}
}
this.push(result);
callback();
},
});
}