Compare commits

..

1 Commits

Author SHA1 Message Date
Santo Shakil 49714a3b30 fix(mobile): permanently delete local copies when moving to the locked folder 2026-07-08 14:18:20 +06:00
49 changed files with 415 additions and 14933 deletions
+4 -4
View File
@@ -78,10 +78,10 @@ jobs:
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
if: ${{ !github.event.pull_request.head.repo.fork }} if: ${{ !github.event.pull_request.head.repo.fork }}
with: with:
registry: ghcr.io registry: ghcr.io
@@ -96,7 +96,7 @@ jobs:
- name: Generate docker image tags - name: Generate docker image tags
id: metadata id: metadata
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with: with:
flavor: | flavor: |
latest=false latest=false
@@ -107,7 +107,7 @@ jobs:
type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }} type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }}
- name: Build and push image - name: Build and push image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with: with:
file: packages/cli/Dockerfile file: packages/cli/Dockerfile
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
+3 -3
View File
@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # 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). # 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) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
# ️ Command-line programs to run using the OS shell. # ️ 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 # 📚 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 # ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with: with:
category: '/language:${{matrix.language}}' category: '/language:${{matrix.language}}'
+2 -2
View File
@@ -60,7 +60,7 @@ jobs:
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn'] suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
steps: steps:
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -90,7 +90,7 @@ jobs:
suffix: [''] suffix: ['']
steps: steps:
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
+3 -3
View File
@@ -30,7 +30,7 @@ This environment includes the services below. Additional details are available i
- Redis - Redis
- PostgreSQL development database with exposed port `5432` so you can use any database client to access it - PostgreSQL development database with exposed port `5432` so you can use any database client to access it
All the services are packaged to run with a single Docker Compose command. All the services are packaged to run as with single Docker Compose command.
:::tip mise :::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`. [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 with a single Docker Compose command.
1. Clone the project repo. 1. Clone the project repo.
2. Run `cp docker/example.env docker/.env`. 2. Run `cp docker/example.env docker/.env`.
3. Edit `docker/.env` to provide values for the required variable `UPLOAD_LOCATION`. 3. Edit `docker/.env` to provide values for the required variable `UPLOAD_LOCATION`.
4. Install dependencies - `mise x -- pnpm i` 4. Install dependencies - `pnpm i`
5. From the root directory, run: 5. From the root directory, run:
```bash title="Start development server" ```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. 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` 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`
**Notes:** **Notes:**
+1
View File
@@ -1462,6 +1462,7 @@
"move_to_lock_folder_action_prompt": "{count} added to the locked folder", "move_to_lock_folder_action_prompt": "{count} added to the locked folder",
"move_to_locked_folder": "Move to locked folder", "move_to_locked_folder": "Move to locked folder",
"move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder", "move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder",
"move_to_locked_folder_local_ios": "These items will be moved to Recently Deleted but still be available on the Immich server",
"moved_to_trash": "Moved to trash", "moved_to_trash": "Moved to trash",
"mute_memories": "Mute Memories", "mute_memories": "Mute Memories",
"my_albums": "My albums", "my_albums": "My albums",
File diff suppressed because it is too large Load Diff
@@ -70,8 +70,7 @@
debugDocumentVersioning = "YES" debugDocumentVersioning = "YES"
debugServiceExtension = "internal" debugServiceExtension = "internal"
enableGPUValidationMode = "1" enableGPUValidationMode = "1"
allowLocationSimulation = "YES" allowLocationSimulation = "YES">
queueDebuggingEnabled = "NO">
<BuildableProductRunnable <BuildableProductRunnable
runnableDebuggingMode = "0"> runnableDebuggingMode = "0">
<BuildableReference <BuildableReference
+14 -60
View File
@@ -1,65 +1,19 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class CropAspectRatio { enum AspectRatioPreset {
final int? numerator; free(ratio: null, label: 'Free', icon: Icons.crop_free_rounded),
final int? denominator; 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);
final String? customLabel; final double? ratio;
final IconData? icon; final String label;
final IconData icon;
final bool iconRotated;
const CropAspectRatio({this.numerator, this.denominator, this.customLabel, this.icon}); const AspectRatioPreset({required this.ratio, required this.label, required this.icon, this.iconRotated = false});
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,6 +27,7 @@ sealed class BaseAsset {
final int? height; final int? height;
final int? durationMs; final int? durationMs;
final bool isFavorite; final bool isFavorite;
final String? livePhotoVideoId;
final bool isEdited; final bool isEdited;
const BaseAsset({ const BaseAsset({
@@ -39,15 +40,32 @@ sealed class BaseAsset {
this.height, this.height,
this.durationMs, this.durationMs,
this.isFavorite = false, this.isFavorite = false,
this.livePhotoVideoId,
required this.isEdited, required this.isEdited,
}); });
bool get isImage => type == AssetType.image; bool get isImage => type == AssetType.image;
bool get isVideo => type == AssetType.video; bool get isVideo => type == AssetType.video;
bool get isMotionPhoto => playbackStyle == AssetPlaybackStyle.livePhoto; bool get isMotionPhoto => livePhotoVideoId != null;
bool get isAnimatedImage => playbackStyle == AssetPlaybackStyle.imageAnimated; 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 { Duration get duration {
final durationMs = this.durationMs; final durationMs = this.durationMs;
if (durationMs != null) { if (durationMs != null) {
@@ -68,7 +86,6 @@ sealed class BaseAsset {
String? get localId; String? get localId;
String? get remoteId; String? get remoteId;
String get heroTag; String get heroTag;
AssetPlaybackStyle get playbackStyle;
@override @override
String toString() { String toString() {
@@ -25,6 +25,7 @@ class LocalAsset extends BaseAsset {
super.height, super.height,
super.durationMs, super.durationMs,
super.isFavorite = false, super.isFavorite = false,
super.livePhotoVideoId,
this.orientation = 0, this.orientation = 0,
required this.playbackStyle, required this.playbackStyle,
this.adjustmentTime, this.adjustmentTime,
@@ -10,7 +10,6 @@ class RemoteAsset extends BaseAsset {
final AssetVisibility visibility; final AssetVisibility visibility;
final String ownerId; final String ownerId;
final String? stackId; final String? stackId;
final String? livePhotoVideoId;
final DateTime? uploadedAt; final DateTime? uploadedAt;
final DateTime? deletedAt; final DateTime? deletedAt;
@@ -30,7 +29,7 @@ class RemoteAsset extends BaseAsset {
super.isFavorite = false, super.isFavorite = false,
this.thumbHash, this.thumbHash,
this.visibility = AssetVisibility.timeline, this.visibility = AssetVisibility.timeline,
this.livePhotoVideoId, super.livePhotoVideoId,
this.stackId, this.stackId,
required super.isEdited, required super.isEdited,
this.deletedAt, this.deletedAt,
@@ -39,23 +38,6 @@ class RemoteAsset extends BaseAsset {
@override @override
String? get localId => localAssetId; 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 @override
String? get remoteId => id; String? get remoteId => id;
@@ -108,7 +90,6 @@ class RemoteAsset extends BaseAsset {
thumbHash == other.thumbHash && thumbHash == other.thumbHash &&
visibility == other.visibility && visibility == other.visibility &&
stackId == other.stackId && stackId == other.stackId &&
livePhotoVideoId == other.livePhotoVideoId &&
uploadedAt == other.uploadedAt && uploadedAt == other.uploadedAt &&
deletedAt == other.deletedAt; deletedAt == other.deletedAt;
} }
@@ -122,7 +103,6 @@ class RemoteAsset extends BaseAsset {
thumbHash.hashCode ^ thumbHash.hashCode ^
visibility.hashCode ^ visibility.hashCode ^
stackId.hashCode ^ stackId.hashCode ^
livePhotoVideoId.hashCode ^
uploadedAt.hashCode ^ uploadedAt.hashCode ^
deletedAt.hashCode; deletedAt.hashCode;
@@ -26,10 +26,6 @@ class AssetService {
return _localRepository.getByChecksum(checksum); return _localRepository.getByChecksum(checksum);
} }
Future<LocalAsset?> getLocalAsset(String id) {
return _localRepository.get(id);
}
Future<RemoteAsset?> getRemoteAssetByChecksum(String checksum) { Future<RemoteAsset?> getRemoteAssetByChecksum(String checksum) {
return _remoteRepository.getByChecksum(checksum); return _remoteRepository.getByChecksum(checksum);
} }
@@ -85,12 +85,8 @@ class TimelineFactory {
TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) => TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type)); TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type));
/// Creates a TimelineService for serving geographical map queries, such assets within bounded locations TimelineService map(List<String> userIds, TimelineMapOptions options) =>
TimelineService geographicMap( TimelineService(_timelineRepository.map(userIds, options, groupBy));
List<String> userIds,
TimelineMapOptions Function() currentOptions,
Stream<TimelineMapOptions> optionsStream,
) => TimelineService(_timelineRepository.geographicMap(userIds, currentOptions, optionsStream, groupBy));
} }
class TimelineService { class TimelineService {
@@ -21,7 +21,6 @@ WHERE (library_id IS NOT NULL);
CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created
ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC) ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)
''') ''')
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_remote_asset_uploaded ON remote_asset_entity (uploaded_at)')
class RemoteAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin { class RemoteAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
const RemoteAssetEntity(); const RemoteAssetEntity();
@@ -1779,7 +1779,3 @@ i0.Index get idxRemoteAssetOwnerVisibilityDeletedCreated => i0.Index(
'idx_remote_asset_owner_visibility_deleted_created', 'idx_remote_asset_owner_visibility_deleted_created',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)', 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)',
); );
i0.Index get idxRemoteAssetUploaded => i0.Index(
'idx_remote_asset_uploaded',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_uploaded ON remote_asset_entity (uploaded_at)',
);
@@ -120,7 +120,7 @@ class Drift extends $Drift {
} }
@override @override
int get schemaVersion => 31; int get schemaVersion => 30;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@@ -128,209 +128,199 @@ class Drift extends $Drift {
// Run migration steps without foreign keys and re-enable them later // Run migration steps without foreign keys and re-enable them later
await customStatement('PRAGMA foreign_keys = OFF'); await customStatement('PRAGMA foreign_keys = OFF');
try { await m.runMigrationSteps(
await transaction( from: from,
() => m.runMigrationSteps( to: to,
from: from, steps: migrationSteps(
to: to, from1To2: (m, v2) async {
steps: migrationSteps( for (final entity in v2.entities) {
from1To2: (m, v2) async { await m.drop(entity);
for (final entity in v2.entities) { await m.create(entity);
await m.drop(entity); }
await m.create(entity); },
} from2To3: (m, v3) async {
}, // Removed foreign key constraint on stack.primaryAssetId
from2To3: (m, v3) async { await m.alterTable(TableMigration(v3.stackEntity));
// Removed foreign key constraint on stack.primaryAssetId },
await m.alterTable(TableMigration(v3.stackEntity)); from3To4: (m, v4) async {
}, // Thumbnail path column got removed from person_entity
from3To4: (m, v4) async { await m.alterTable(TableMigration(v4.personEntity));
// Thumbnail path column got removed from person_entity // asset_face_entity is added
await m.alterTable(TableMigration(v4.personEntity)); await m.create(v4.assetFaceEntity);
// asset_face_entity is added },
await m.create(v4.assetFaceEntity); from4To5: (m, v5) async {
}, await m.alterTable(
from4To5: (m, v5) async { TableMigration(
await m.alterTable( v5.userEntity,
TableMigration( newColumns: [v5.userEntity.hasProfileImage, v5.userEntity.profileChangedAt],
v5.userEntity, columnTransformer: {v5.userEntity.profileChangedAt: currentDateAndTime},
newColumns: [v5.userEntity.hasProfileImage, v5.userEntity.profileChangedAt], ),
columnTransformer: {v5.userEntity.profileChangedAt: currentDateAndTime}, );
), },
); from5To6: (m, v6) async {
}, // Drops the (checksum, ownerId) and adds it back as (ownerId, checksum)
from5To6: (m, v6) async { await customStatement('DROP INDEX IF EXISTS UQ_remote_asset_owner_checksum');
// Drops the (checksum, ownerId) and adds it back as (ownerId, checksum) await m.drop(v6.idxRemoteAssetOwnerChecksum);
await customStatement('DROP INDEX IF EXISTS UQ_remote_asset_owner_checksum'); await m.create(v6.idxRemoteAssetOwnerChecksum);
await m.drop(v6.idxRemoteAssetOwnerChecksum); // Adds libraryId to remote_asset_entity
await m.create(v6.idxRemoteAssetOwnerChecksum); await m.addColumn(v6.remoteAssetEntity, v6.remoteAssetEntity.libraryId);
// Adds libraryId to remote_asset_entity await m.drop(v6.uQRemoteAssetsOwnerChecksum);
await m.addColumn(v6.remoteAssetEntity, v6.remoteAssetEntity.libraryId); await m.create(v6.uQRemoteAssetsOwnerChecksum);
await m.drop(v6.uQRemoteAssetsOwnerChecksum); await m.drop(v6.uQRemoteAssetsOwnerLibraryChecksum);
await m.create(v6.uQRemoteAssetsOwnerChecksum); await m.create(v6.uQRemoteAssetsOwnerLibraryChecksum);
await m.drop(v6.uQRemoteAssetsOwnerLibraryChecksum); },
await m.create(v6.uQRemoteAssetsOwnerLibraryChecksum); from6To7: (m, v7) async {
}, await m.createIndex(v7.idxLatLng);
from6To7: (m, v7) async { },
await m.createIndex(v7.idxLatLng); from7To8: (m, v8) async {
}, await m.create(v8.storeEntity);
from7To8: (m, v8) async { },
await m.create(v8.storeEntity); from8To9: (m, v9) async {
}, await m.addColumn(v9.localAlbumEntity, v9.localAlbumEntity.linkedRemoteAlbumId);
from8To9: (m, v9) async { },
await m.addColumn(v9.localAlbumEntity, v9.localAlbumEntity.linkedRemoteAlbumId); from9To10: (m, v10) async {
}, await m.createTable(v10.authUserEntity);
from9To10: (m, v10) async { await m.addColumn(v10.userEntity, v10.userEntity.avatarColor);
await m.createTable(v10.authUserEntity); await m.alterTable(TableMigration(v10.userEntity));
await m.addColumn(v10.userEntity, v10.userEntity.avatarColor); },
await m.alterTable(TableMigration(v10.userEntity)); from10To11: (m, v11) async {
}, await m.addColumn(v11.localAlbumAssetEntity, v11.localAlbumAssetEntity.marker_);
from10To11: (m, v11) async { },
await m.addColumn(v11.localAlbumAssetEntity, v11.localAlbumAssetEntity.marker_); from11To12: (m, v12) async {
}, final localToUTCMapping = {
from11To12: (m, v12) async { v12.localAssetEntity: [v12.localAssetEntity.createdAt, v12.localAssetEntity.updatedAt],
final localToUTCMapping = { v12.localAlbumEntity: [v12.localAlbumEntity.updatedAt],
v12.localAssetEntity: [v12.localAssetEntity.createdAt, v12.localAssetEntity.updatedAt], };
v12.localAlbumEntity: [v12.localAlbumEntity.updatedAt],
};
for (final entry in localToUTCMapping.entries) { for (final entry in localToUTCMapping.entries) {
final table = entry.key; final table = entry.key;
await m.alterTable( await m.alterTable(
TableMigration( TableMigration(
table, table,
columnTransformer: { columnTransformer: {
for (final column in entry.value) for (final column in entry.value)
column: column.modify(const DateTimeModifier.utc()).strftime('%Y-%m-%dT%H:%M:%fZ'), column: column.modify(const DateTimeModifier.utc()).strftime('%Y-%m-%dT%H:%M:%fZ'),
}, },
), ),
); );
} }
}, },
from12To13: (m, v13) async { from12To13: (m, v13) async {
await m.create(v13.trashedLocalAssetEntity); await m.create(v13.trashedLocalAssetEntity);
await m.createIndex(v13.idxTrashedLocalAssetChecksum); await m.createIndex(v13.idxTrashedLocalAssetChecksum);
await m.createIndex(v13.idxTrashedLocalAssetAlbum); await m.createIndex(v13.idxTrashedLocalAssetAlbum);
}, },
from13To14: (m, v14) async { from13To14: (m, v14) async {
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.adjustmentTime); await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.adjustmentTime);
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.latitude); await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.latitude);
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.longitude); await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.longitude);
}, },
from14To15: (m, v15) async { from14To15: (m, v15) async {
await m.alterTable( await m.alterTable(
TableMigration( TableMigration(
v15.trashedLocalAssetEntity, v15.trashedLocalAssetEntity,
columnTransformer: {v15.trashedLocalAssetEntity.source: Constant(TrashOrigin.localSync.index)}, columnTransformer: {v15.trashedLocalAssetEntity.source: Constant(TrashOrigin.localSync.index)},
newColumns: [v15.trashedLocalAssetEntity.source], newColumns: [v15.trashedLocalAssetEntity.source],
), ),
); );
}, },
from15To16: (m, v16) async { from15To16: (m, v16) async {
// Add i_cloud_id to local and remote asset tables // Add i_cloud_id to local and remote asset tables
await m.addColumn(v16.localAssetEntity, v16.localAssetEntity.iCloudId); await m.addColumn(v16.localAssetEntity, v16.localAssetEntity.iCloudId);
await m.createIndex(v16.idxLocalAssetCloudId); await m.createIndex(v16.idxLocalAssetCloudId);
await m.createTable(v16.remoteAssetCloudIdEntity); await m.createTable(v16.remoteAssetCloudIdEntity);
}, },
from16To17: (m, v17) async { from16To17: (m, v17) async {
await m.addColumn(v17.remoteAssetEntity, v17.remoteAssetEntity.isEdited); await m.addColumn(v17.remoteAssetEntity, v17.remoteAssetEntity.isEdited);
}, },
from17To18: (m, v18) async { from17To18: (m, v18) async {
await m.createIndex(v18.idxRemoteAssetCloudId); await m.createIndex(v18.idxRemoteAssetCloudId);
}, },
from18To19: (m, v19) async { from18To19: (m, v19) async {
await m.createIndex(v19.idxAssetFacePersonId); await m.createIndex(v19.idxAssetFacePersonId);
await m.createIndex(v19.idxAssetFaceAssetId); await m.createIndex(v19.idxAssetFaceAssetId);
await m.createIndex(v19.idxLocalAlbumAssetAlbumAsset); await m.createIndex(v19.idxLocalAlbumAssetAlbumAsset);
await m.createIndex(v19.idxPartnerSharedWithId); await m.createIndex(v19.idxPartnerSharedWithId);
await m.createIndex(v19.idxPersonOwnerId); await m.createIndex(v19.idxPersonOwnerId);
await m.createIndex(v19.idxRemoteAlbumOwnerId); await m.createIndex(v19.idxRemoteAlbumOwnerId);
await m.createIndex(v19.idxRemoteAlbumAssetAlbumAsset); await m.createIndex(v19.idxRemoteAlbumAssetAlbumAsset);
await m.createIndex(v19.idxRemoteAssetStackId); await m.createIndex(v19.idxRemoteAssetStackId);
await m.createIndex(v19.idxRemoteAssetLocalDateTimeDay); await m.createIndex(v19.idxRemoteAssetLocalDateTimeDay);
await m.createIndex(v19.idxRemoteAssetLocalDateTimeMonth); await m.createIndex(v19.idxRemoteAssetLocalDateTimeMonth);
await m.createIndex(v19.idxStackPrimaryAssetId); await m.createIndex(v19.idxStackPrimaryAssetId);
}, },
from19To20: (m, v20) async { from19To20: (m, v20) async {
await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.isVisible); await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.isVisible);
await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.deletedAt); await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.deletedAt);
}, },
from20To21: (m, v21) async { from20To21: (m, v21) async {
await m.addColumn(v21.localAssetEntity, v21.localAssetEntity.playbackStyle); await m.addColumn(v21.localAssetEntity, v21.localAssetEntity.playbackStyle);
await m.addColumn(v21.trashedLocalAssetEntity, v21.trashedLocalAssetEntity.playbackStyle); await m.addColumn(v21.trashedLocalAssetEntity, v21.trashedLocalAssetEntity.playbackStyle);
}, },
from21To22: (m, v22) async { from21To22: (m, v22) async {
await m.createTable(v22.assetEditEntity); await m.createTable(v22.assetEditEntity);
await m.createIndex(v22.idxAssetEditAssetId); await m.createIndex(v22.idxAssetEditAssetId);
}, },
from22To23: (m, v23) async { from22To23: (m, v23) async {
await m.renameColumn(v23.localAssetEntity, 'duration_in_seconds', v23.localAssetEntity.durationMs); await m.renameColumn(v23.localAssetEntity, 'duration_in_seconds', v23.localAssetEntity.durationMs);
await m.renameColumn(v23.remoteAssetEntity, 'duration_in_seconds', v23.remoteAssetEntity.durationMs); await m.renameColumn(v23.remoteAssetEntity, 'duration_in_seconds', v23.remoteAssetEntity.durationMs);
await m.renameColumn( await m.renameColumn(
v23.trashedLocalAssetEntity, v23.trashedLocalAssetEntity,
'duration_in_seconds', 'duration_in_seconds',
v23.trashedLocalAssetEntity.durationMs, v23.trashedLocalAssetEntity.durationMs,
); );
await localAssetEntity.update().write( await localAssetEntity.update().write(
LocalAssetEntityCompanion.custom(durationMs: v23.localAssetEntity.durationMs * const Constant(1000)), LocalAssetEntityCompanion.custom(durationMs: v23.localAssetEntity.durationMs * const Constant(1000)),
); );
await remoteAssetEntity.update().write( await remoteAssetEntity.update().write(
RemoteAssetEntityCompanion.custom( RemoteAssetEntityCompanion.custom(durationMs: v23.remoteAssetEntity.durationMs * const Constant(1000)),
durationMs: v23.remoteAssetEntity.durationMs * const Constant(1000), );
), await trashedLocalAssetEntity.update().write(
); TrashedLocalAssetEntityCompanion.custom(
await trashedLocalAssetEntity.update().write( durationMs: v23.trashedLocalAssetEntity.durationMs * const Constant(1000),
TrashedLocalAssetEntityCompanion.custom( ),
durationMs: v23.trashedLocalAssetEntity.durationMs * const Constant(1000), );
), },
); from23To24: (m, v24) async {
}, await customStatement('DROP INDEX IF EXISTS idx_remote_album_owner_id');
from23To24: (m, v24) async { await m.alterTable(TableMigration(v24.remoteAlbumEntity));
await customStatement('DROP INDEX IF EXISTS idx_remote_album_owner_id'); },
await m.alterTable(TableMigration(v24.remoteAlbumEntity)); from24To25: (m, v25) async {
}, await m.createTable(v25.metadata);
from24To25: (m, v25) async { await customStatement('DROP INDEX IF EXISTS idx_remote_asset_owner_checksum');
await m.createTable(v25.metadata); await customStatement('DROP INDEX IF EXISTS idx_remote_asset_local_date_time_day');
await customStatement('DROP INDEX IF EXISTS idx_remote_asset_owner_checksum'); await customStatement('DROP INDEX IF EXISTS idx_remote_asset_local_date_time_month');
await customStatement('DROP INDEX IF EXISTS idx_remote_asset_local_date_time_day'); await m.createIndex(v25.idxRemoteAssetOwnerVisibilityDeletedCreated);
await customStatement('DROP INDEX IF EXISTS idx_remote_asset_local_date_time_month'); await m.createIndex(v25.idxRemoteExifCity);
await m.createIndex(v25.idxRemoteAssetOwnerVisibilityDeletedCreated); await m.createIndex(v25.idxAssetFaceVisiblePerson);
await m.createIndex(v25.idxRemoteExifCity); },
await m.createIndex(v25.idxAssetFaceVisiblePerson); from25To26: (m, v26) async {
}, await m.addColumn(v26.remoteAssetEntity, v26.remoteAssetEntity.uploadedAt);
from25To26: (m, v26) async { },
await m.addColumn(v26.remoteAssetEntity, v26.remoteAssetEntity.uploadedAt); from26To27: (m, v27) async {
}, await customStatement('ALTER TABLE metadata RENAME TO settings');
from26To27: (m, v27) async { },
await customStatement('ALTER TABLE metadata RENAME TO settings'); from27To28: (m, v28) async {
}, await m.createIndex(v28.idxLocalAssetCreatedAt);
from27To28: (m, v28) async { },
await m.createIndex(v28.idxLocalAssetCreatedAt); from28To29: (m, v29) async {
}, await m.createTable(v29.assetOcrEntity);
from28To29: (m, v29) async { await m.createIndex(v29.idxAssetOcrAssetId);
await m.createTable(v29.assetOcrEntity); },
await m.createIndex(v29.idxAssetOcrAssetId); from29To30: (m, v30) async {
}, await m.alterTable(TableMigration(v30.settings));
from29To30: (m, v30) async { },
await m.alterTable(TableMigration(v30.settings)); ),
}, );
from30To31: (m, v31) async {
await m.createIndex(v31.idxRemoteAssetUploaded);
},
),
),
);
if (kDebugMode) { if (kDebugMode) {
// Fail if the migration broke foreign keys // Fail if the migration broke foreign keys
final wrongFKs = await customSelect('PRAGMA foreign_key_check').get(); final wrongFKs = await customSelect('PRAGMA foreign_key_check').get();
assert(wrongFKs.isEmpty, '${wrongFKs.map((e) => e.data)}'); assert(wrongFKs.isEmpty, '${wrongFKs.map((e) => e.data)}');
}
} finally {
await customStatement('PRAGMA foreign_keys = ON;');
} }
await customStatement('PRAGMA foreign_keys = ON;');
await optimize(); await optimize();
}, },
beforeOpen: (details) async { beforeOpen: (details) async {
@@ -124,7 +124,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
i2.idxRemoteAssetChecksum, i2.idxRemoteAssetChecksum,
i2.idxRemoteAssetStackId, i2.idxRemoteAssetStackId,
i2.idxRemoteAssetOwnerVisibilityDeletedCreated, i2.idxRemoteAssetOwnerVisibilityDeletedCreated,
i2.idxRemoteAssetUploaded,
authUserEntity, authUserEntity,
userMetadataEntity, userMetadataEntity,
partnerEntity, partnerEntity,
@@ -15920,592 +15920,6 @@ i1.GeneratedColumn<String> _column_224(String aliasedName) =>
type: i1.DriftSqlType.string, type: i1.DriftSqlType.string,
$customConstraints: 'NULL', $customConstraints: 'NULL',
); );
final class Schema31 extends i0.VersionedSchema {
Schema31({required super.database}) : super(version: 31);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAlbumAssetAlbumAsset,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxLocalAssetCreatedAt,
idxStackPrimaryAssetId,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
idxRemoteAssetStackId,
idxRemoteAssetOwnerVisibilityDeletedCreated,
idxRemoteAssetUploaded,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
assetEditEntity,
settings,
assetOcrEntity,
idxPartnerSharedWithId,
idxLatLng,
idxRemoteExifCity,
idxRemoteAlbumAssetAlbumAsset,
idxRemoteAssetCloudId,
idxPersonOwnerId,
idxAssetFacePersonId,
idxAssetFaceAssetId,
idxAssetFaceVisiblePerson,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
idxAssetEditAssetId,
idxAssetOcrAssetId,
];
late final Shape33 userEntity = Shape33(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_110,
_column_111,
_column_112,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape50 remoteAssetEntity = Shape50(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_119,
_column_120,
_column_121,
_column_122,
_column_123,
_column_124,
_column_212,
_column_125,
_column_126,
_column_127,
_column_128,
_column_129,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape35 stackEntity = Shape35(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_130,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape36 localAssetEntity = Shape36(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_131,
_column_120,
_column_132,
_column_133,
_column_134,
_column_135,
_column_136,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape48 remoteAlbumEntity = Shape48(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_138,
_column_114,
_column_115,
_column_139,
_column_140,
_column_141,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape38 localAlbumEntity = Shape38(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_115,
_column_142,
_column_143,
_column_144,
_column_145,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape39 localAlbumAssetEntity = Shape39(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_146, _column_147, _column_145],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
'idx_local_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxLocalAssetCloudId = i1.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
final i1.Index idxLocalAssetCreatedAt = i1.Index(
'idx_local_asset_created_at',
'CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)',
);
final i1.Index idxStackPrimaryAssetId = i1.Index(
'idx_stack_primary_asset_id',
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
final i1.Index idxRemoteAssetStackId = i1.Index(
'idx_remote_asset_stack_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
);
final i1.Index idxRemoteAssetOwnerVisibilityDeletedCreated = i1.Index(
'idx_remote_asset_owner_visibility_deleted_created',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)',
);
final i1.Index idxRemoteAssetUploaded = i1.Index(
'idx_remote_asset_uploaded',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_uploaded ON remote_asset_entity (uploaded_at)',
);
late final Shape40 authUserEntity = Shape40(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_148,
_column_110,
_column_111,
_column_149,
_column_150,
_column_151,
_column_152,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_153, _column_154, _column_155],
attachedDatabase: database,
),
alias: null,
);
late final Shape41 partnerEntity = Shape41(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_156, _column_157, _column_158],
attachedDatabase: database,
),
alias: null,
);
late final Shape42 remoteExifEntity = Shape42(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_160,
_column_161,
_column_162,
_column_163,
_column_164,
_column_117,
_column_116,
_column_165,
_column_166,
_column_167,
_column_168,
_column_135,
_column_136,
_column_169,
_column_170,
_column_171,
_column_172,
_column_173,
_column_174,
_column_175,
_column_176,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_159, _column_177],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_177, _column_153, _column_178],
attachedDatabase: database,
),
alias: null,
);
late final Shape43 remoteAssetCloudIdEntity = Shape43(
source: i0.VersionedTable(
entityName: 'remote_asset_cloud_id_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_179,
_column_180,
_column_134,
_column_135,
_column_136,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape44 memoryEntity = Shape44(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_124,
_column_121,
_column_113,
_column_181,
_column_182,
_column_183,
_column_184,
_column_185,
_column_186,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_159, _column_187],
attachedDatabase: database,
),
alias: null,
);
late final Shape45 personEntity = Shape45(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_108,
_column_188,
_column_189,
_column_190,
_column_191,
_column_192,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape46 assetFaceEntity = Shape46(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_193,
_column_194,
_column_195,
_column_196,
_column_197,
_column_198,
_column_199,
_column_200,
_column_201,
_column_124,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_202, _column_203, _column_204],
attachedDatabase: database,
),
alias: null,
);
late final Shape47 trashedLocalAssetEntity = Shape47(
source: i0.VersionedTable(
entityName: 'trashed_local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id, album_id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_205,
_column_131,
_column_120,
_column_132,
_column_206,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape32 assetEditEntity = Shape32(
source: i0.VersionedTable(
entityName: 'asset_edit_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_207,
_column_208,
_column_209,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape49 settings = Shape49(
source: i0.VersionedTable(
entityName: 'settings',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY("key")'],
columns: [_column_210, _column_224, _column_115],
attachedDatabase: database,
),
alias: null,
);
late final Shape51 assetOcrEntity = Shape51(
source: i0.VersionedTable(
entityName: 'asset_ocr_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_213,
_column_214,
_column_215,
_column_216,
_column_217,
_column_218,
_column_219,
_column_220,
_column_221,
_column_222,
_column_223,
_column_201,
],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxPartnerSharedWithId = i1.Index(
'idx_partner_shared_with_id',
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
final i1.Index idxRemoteExifCity = i1.Index(
'idx_remote_exif_city',
'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL',
);
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
'idx_remote_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxRemoteAssetCloudId = i1.Index(
'idx_remote_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
);
final i1.Index idxPersonOwnerId = i1.Index(
'idx_person_owner_id',
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
);
final i1.Index idxAssetFacePersonId = i1.Index(
'idx_asset_face_person_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
);
final i1.Index idxAssetFaceAssetId = i1.Index(
'idx_asset_face_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
);
final i1.Index idxAssetFaceVisiblePerson = i1.Index(
'idx_asset_face_visible_person',
'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL',
);
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
'idx_trashed_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
);
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
'idx_trashed_local_asset_album',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
);
final i1.Index idxAssetEditAssetId = i1.Index(
'idx_asset_edit_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
);
final i1.Index idxAssetOcrAssetId = i1.Index(
'idx_asset_ocr_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_ocr_asset_id ON asset_ocr_entity (asset_id)',
);
}
i0.MigrationStepWithVersion migrationSteps({ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2, required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3, required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -16536,7 +15950,6 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28, required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29, required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30, required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
required Future<void> Function(i1.Migrator m, Schema31 schema) from30To31,
}) { }) {
return (currentVersion, database) async { return (currentVersion, database) async {
switch (currentVersion) { switch (currentVersion) {
@@ -16685,11 +16098,6 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema); final migrator = i1.Migrator(database, schema);
await from29To30(migrator, schema); await from29To30(migrator, schema);
return 30; return 30;
case 30:
final schema = Schema31(database: database);
final migrator = i1.Migrator(database, schema);
await from30To31(migrator, schema);
return 31;
default: default:
throw ArgumentError.value('Unknown migration from $currentVersion'); throw ArgumentError.value('Unknown migration from $currentVersion');
} }
@@ -16726,7 +16134,6 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28, required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29, required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30, required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
required Future<void> Function(i1.Migrator m, Schema31 schema) from30To31,
}) => i0.VersionedSchema.stepByStepHelper( }) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps( step: migrationSteps(
from1To2: from1To2, from1To2: from1To2,
@@ -16758,6 +16165,5 @@ i1.OnUpgrade stepByStep({
from27To28: from27To28, from27To28: from27To28,
from28To29: from28To29, from28To29: from28To29,
from29To30: from29To30, from29To30: from29To30,
from30To31: from30To31,
), ),
); );
@@ -33,7 +33,6 @@ class StorageRepository {
return file; return file;
} }
// TODO(agg23): Unify these methods
Future<File?> getMotionFileForAsset(LocalAsset asset) async { Future<File?> getMotionFileForAsset(LocalAsset asset) async {
File? file; File? file;
final log = Logger('StorageRepository'); final log = Logger('StorageRepository');
@@ -509,21 +509,9 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
return query.map((row) => row.toDto()).get(); return query.map((row) => row.toDto()).get();
} }
/// Creates a geographic map query that can dynamically filter on changing [TimelineMapOptions] TimelineQuery map(List<String> userIds, TimelineMapOptions options, GroupAssetsBy groupBy) => (
/// (most notably the active map bounds) bucketSource: () => _watchMapBucket(userIds, options, groupBy: groupBy),
TimelineQuery geographicMap( assetSource: (offset, count) => _getMapBucketAssets(userIds, options, offset: offset, count: count),
List<String> userIds,
TimelineMapOptions Function() currentOptions,
Stream<TimelineMapOptions> optionsStream,
GroupAssetsBy groupBy,
) => (
bucketSource: () => Stream.value(currentOptions())
.followedBy(optionsStream)
.switchMap(
// Any error would kill the stream for all options; make sure the stream stays alive
(options) => _watchMapBucket(userIds, options, groupBy: groupBy).handleError((_) {}),
),
assetSource: (offset, count) => _getMapBucketAssets(userIds, currentOptions(), offset: offset, count: count),
origin: TimelineOrigin.map, origin: TimelineOrigin.map,
); );
@@ -626,7 +614,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
return ( return (
bucketSource: () => _watchRemoteBucket(filter: filter, groupBy: groupBy, sortBy: sortBy), bucketSource: () => _watchRemoteBucket(filter: filter, groupBy: groupBy, sortBy: sortBy),
assetSource: (offset, count) => assetSource: (offset, count) =>
_getRemoteAssets(filter: filter, offset: offset, count: count, joinLocal: joinLocal, sortBy: sortBy), _getRemoteAssets(filter: filter, offset: offset, count: count, joinLocal: joinLocal),
origin: origin, origin: origin,
); );
} }
@@ -663,7 +651,6 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
required int offset, required int offset,
required int count, required int count,
bool joinLocal = false, bool joinLocal = false,
SortAssetsBy sortBy = SortAssetsBy.taken,
}) { }) {
if (joinLocal) { if (joinLocal) {
final query = final query =
@@ -676,11 +663,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
]) ])
..addColumns([_db.localAssetEntity.id]) ..addColumns([_db.localAssetEntity.id])
..where(filter(_db.remoteAssetEntity)) ..where(filter(_db.remoteAssetEntity))
..orderBy([ ..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)])
OrderingTerm.desc(
sortBy == SortAssetsBy.uploaded ? _db.remoteAssetEntity.uploadedAt : _db.remoteAssetEntity.createdAt,
),
])
..limit(count, offset: offset); ..limit(count, offset: offset);
return query return query
@@ -689,7 +672,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
} else { } else {
final query = _db.remoteAssetEntity.select() final query = _db.remoteAssetEntity.select()
..where(filter) ..where(filter)
..orderBy([(row) => OrderingTerm.desc(sortBy == SortAssetsBy.uploaded ? row.uploadedAt : row.createdAt)]) ..orderBy([(row) => OrderingTerm.desc(row.createdAt)])
..limit(count, offset: offset); ..limit(count, offset: offset);
return query.map((row) => row.toDto()).get(); return query.map((row) => row.toDto()).get();
@@ -114,6 +114,7 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
_PropertyItem(label: 'Height', value: asset.height?.toString()), _PropertyItem(label: 'Height', value: asset.height?.toString()),
_PropertyItem(label: 'Duration', value: asset.durationMs != null ? '${asset.durationMs} ms' : null), _PropertyItem(label: 'Duration', value: asset.durationMs != null ? '${asset.durationMs} ms' : null),
_PropertyItem(label: 'Is Favorite', value: asset.isFavorite.toString()), _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()), _PropertyItem(label: 'Is Edited', value: asset.isEdited.toString()),
]); ]);
} }
@@ -150,7 +151,6 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
_PropertyItem(label: 'Thumb Hash', value: asset.thumbHash), _PropertyItem(label: 'Thumb Hash', value: asset.thumbHash),
_PropertyItem(label: 'Visibility', value: asset.visibility.toString()), _PropertyItem(label: 'Visibility', value: asset.visibility.toString()),
_PropertyItem(label: 'Stack ID', value: asset.stackId), _PropertyItem(label: 'Stack ID', value: asset.stackId),
_PropertyItem(label: 'Live Photo Video ID', value: asset.livePhotoVideoId),
]; ];
properties.insertAll(4, additionalProps); properties.insertAll(4, additionalProps);
@@ -154,7 +154,7 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
} }
class _AspectRatioButton extends StatelessWidget { class _AspectRatioButton extends StatelessWidget {
final CropAspectRatio ratio; final AspectRatioPreset ratio;
final bool isSelected; final bool isSelected;
final VoidCallback onPressed; final VoidCallback onPressed;
@@ -162,16 +162,15 @@ class _AspectRatioButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final color = isSelected ? context.primaryColor : context.themeData.iconTheme.color;
return Column( return Column(
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
children: [ children: [
IconButton( IconButton(
iconSize: 36, iconSize: 36,
icon: ratio.ratio != null icon: Transform.rotate(
? _AspectRatioRect(ratio: ratio.ratio!, color: color) angle: ratio.iconRotated ? pi / 2 : 0,
: Icon(ratio.icon, color: color), child: Icon(ratio.icon, color: isSelected ? context.primaryColor : context.themeData.iconTheme.color),
),
onPressed: onPressed, onPressed: onPressed,
), ),
Text(ratio.label, style: context.textTheme.displayMedium), Text(ratio.label, style: context.textTheme.displayMedium),
@@ -180,32 +179,6 @@ 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 { class _AspectRatioSelector extends ConsumerWidget {
const _AspectRatioSelector(); const _AspectRatioSelector();
@@ -214,16 +187,22 @@ class _AspectRatioSelector extends ConsumerWidget {
final editorState = ref.watch(editorStateProvider); final editorState = ref.watch(editorStateProvider);
final editorNotifier = ref.read(editorStateProvider.notifier); 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( return SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: Row( child: Row(
children: aspectRatioPresets.map((entry) { children: AspectRatioPreset.values.map((entry) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0), padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: _AspectRatioButton( child: _AspectRatioButton(
ratio: entry, ratio: entry,
isSelected: editorState.aspectRatio == entry, isSelected: selectedAspectRatio == entry.ratio,
onPressed: () => editorNotifier.setAspectRatio(entry), onPressed: () => editorNotifier.setAspectRatio(entry.ratio),
), ),
); );
}).toList(), }).toList(),
@@ -378,22 +357,8 @@ class _EditorPreviewState extends ConsumerState<_EditorPreview> with TickerProvi
final editorState = ref.watch(editorStateProvider); final editorState = ref.watch(editorStateProvider);
final editorNotifier = ref.read(editorStateProvider.notifier); final editorNotifier = ref.read(editorStateProvider.notifier);
ref.listen(editorStateProvider, (previous, current) { ref.listen(editorStateProvider, (_, current) {
// Only re-apply the aspect ratio when it changes, otherwise the crop rect will shrink on every rotation cropController.aspectRatio = current.aspectRatio;
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) { if (cropController.crop != current.crop) {
cropController.crop = current.crop; cropController.crop = current.crop;
@@ -421,9 +386,7 @@ class _EditorPreviewState extends ConsumerState<_EditorPreview> with TickerProvi
1.0, 1.0,
1.0, 1.0,
), ),
child: AnimatedContainer( child: Container(
duration: editorState.animationDuration,
curve: Curves.easeInOut,
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
width: (editorState.rotationAngle % 180 == 0) ? baseWidth : baseHeight, width: (editorState.rotationAngle % 180 == 0) ? baseWidth : baseHeight,
height: (editorState.rotationAngle % 180 == 0) ? baseHeight : baseWidth, height: (editorState.rotationAngle % 180 == 0) ? baseHeight : baseWidth,
@@ -1,6 +1,5 @@
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/utils/editor.utils.dart'; import 'package:immich_mobile/utils/editor.utils.dart';
@@ -61,8 +60,13 @@ class EditorProvider extends Notifier<EditorState> {
state = state.copyWith(crop: crop, hasUnsavedEdits: true); state = state.copyWith(crop: crop, hasUnsavedEdits: true);
} }
void setAspectRatio(CropAspectRatio preset) { void setAspectRatio(double? aspectRatio) {
state = state.copyWith(aspectRatio: preset, hasUnsavedEdits: true); 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 resetEdits() { void resetEdits() {
@@ -72,19 +76,19 @@ class EditorProvider extends Notifier<EditorState> {
flipHorizontal: false, flipHorizontal: false,
flipVertical: false, flipVertical: false,
crop: const Rect.fromLTRB(0, 0, 1, 1), crop: const Rect.fromLTRB(0, 0, 1, 1),
aspectRatio: CropAspectRatio.free, aspectRatio: null,
hasUnsavedEdits: true, hasUnsavedEdits: true,
); );
} }
void rotateCCW() { void rotateCCW() {
_animateRotation(state.rotationAngle - 90); _animateRotation(state.rotationAngle - 90);
state = state.copyWith(aspectRatio: state.aspectRatio.flipped, hasUnsavedEdits: true); state = state.copyWith(hasUnsavedEdits: true);
} }
void rotateCW() { void rotateCW() {
_animateRotation(state.rotationAngle + 90); _animateRotation(state.rotationAngle + 90);
state = state.copyWith(aspectRatio: state.aspectRatio.flipped, hasUnsavedEdits: true); state = state.copyWith(hasUnsavedEdits: true);
} }
void flipHorizontally() { void flipHorizontally() {
@@ -113,7 +117,7 @@ class EditorState {
final bool flipHorizontal; final bool flipHorizontal;
final bool flipVertical; final bool flipVertical;
final Rect crop; final Rect crop;
final CropAspectRatio aspectRatio; final double? aspectRatio;
final int originalWidth; final int originalWidth;
final int originalHeight; final int originalHeight;
@@ -128,7 +132,7 @@ class EditorState {
bool? flipHorizontal, bool? flipHorizontal,
bool? flipVertical, bool? flipVertical,
Rect? crop, Rect? crop,
CropAspectRatio? aspectRatio, this.aspectRatio,
int? originalWidth, int? originalWidth,
int? originalHeight, int? originalHeight,
Duration? animationDuration, Duration? animationDuration,
@@ -141,7 +145,6 @@ class EditorState {
originalWidth = originalWidth ?? 0, originalWidth = originalWidth ?? 0,
originalHeight = originalHeight ?? 0, originalHeight = originalHeight ?? 0,
crop = crop ?? const Rect.fromLTRB(0, 0, 1, 1), crop = crop ?? const Rect.fromLTRB(0, 0, 1, 1),
aspectRatio = aspectRatio ?? CropAspectRatio.free,
hasUnsavedEdits = hasUnsavedEdits ?? false; hasUnsavedEdits = hasUnsavedEdits ?? false;
EditorState copyWith({ EditorState copyWith({
@@ -149,7 +152,7 @@ class EditorState {
int? rotationAngle, int? rotationAngle,
bool? flipHorizontal, bool? flipHorizontal,
bool? flipVertical, bool? flipVertical,
CropAspectRatio? aspectRatio, double? aspectRatio = double.infinity,
int? originalWidth, int? originalWidth,
int? originalHeight, int? originalHeight,
Duration? animationDuration, Duration? animationDuration,
@@ -161,7 +164,7 @@ class EditorState {
rotationAngle: rotationAngle ?? this.rotationAngle, rotationAngle: rotationAngle ?? this.rotationAngle,
flipHorizontal: flipHorizontal ?? this.flipHorizontal, flipHorizontal: flipHorizontal ?? this.flipHorizontal,
flipVertical: flipVertical ?? this.flipVertical, flipVertical: flipVertical ?? this.flipVertical,
aspectRatio: aspectRatio ?? this.aspectRatio, aspectRatio: aspectRatio == double.infinity ? this.aspectRatio : aspectRatio,
animationDuration: animationDuration ?? this.animationDuration, animationDuration: animationDuration ?? this.animationDuration,
originalWidth: originalWidth ?? this.originalWidth, originalWidth: originalWidth ?? this.originalWidth,
originalHeight: originalHeight ?? this.originalHeight, originalHeight: originalHeight ?? this.originalHeight,
@@ -16,11 +16,15 @@ Future<void> performMoveToLockFolderAction(BuildContext context, WidgetRef ref,
return; return;
} }
final result = await ref.read(actionProvider.notifier).moveToLockFolder(source, context);
if (result == null) {
return;
}
if (source == ActionSource.viewer) { if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent()); EventStream.shared.emit(const ViewerReloadAssetEvent());
} }
final result = await ref.read(actionProvider.notifier).moveToLockFolder(source);
ref.read(multiSelectProvider.notifier).reset(); ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'move_to_lock_folder_action_prompt'.t( final successMessage = 'move_to_lock_folder_action_prompt'.t(
@@ -122,14 +122,9 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
); );
} }
// Attempt to retrieve LocalAsset, falling back to remote if it cannot be found if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
final localAsset = await _localPlaybackAsset(videoAsset); final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
final file = await StorageRepository().getFileForAsset(id);
if (localAsset != null) {
final file = localAsset.isMotionPhoto
? await StorageRepository().getMotionFileForAsset(localAsset)
: await StorageRepository().getFileForAsset(localAsset.id);
if (!mounted) { if (!mounted) {
return null; return null;
} }
@@ -146,13 +141,14 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
); );
} }
final remoteAsset = videoAsset as RemoteAsset; final remoteId = (videoAsset as RemoteAsset).id;
final serverEndpoint = Store.get(StoreKey.serverEndpoint); final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final isOriginalVideo = ref.read(appConfigProvider).viewer.loadOriginalVideo; final isOriginalVideo = ref.read(appConfigProvider).viewer.loadOriginalVideo;
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback'; final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
final String assetId = remoteAsset.livePhotoVideoId ?? remoteAsset.id; final String videoUrl = videoAsset.livePhotoVideoId != null
final String videoUrl = '$serverEndpoint/assets/$assetId/$postfixUrl'; ? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
return VideoSource.init(path: videoUrl, type: VideoSourceType.network, headers: ApiService.getRequestHeaders()); return VideoSource.init(path: videoUrl, type: VideoSourceType.network, headers: ApiService.getRequestHeaders());
} catch (error) { } catch (error) {
@@ -161,43 +157,6 @@ 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 { void _onPlaybackReady() async {
if (!mounted || !widget.isCurrent) { if (!mounted || !widget.isCurrent) {
return; return;
@@ -35,6 +35,7 @@ class _ScopedMapTimeline extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// TODO: this causes the timeline to switch to flicker to "loading" state and back. This is both janky and inefficient.
return ProviderScope( return ProviderScope(
overrides: [ overrides: [
timelineServiceProvider.overrideWith((ref) { timelineServiceProvider.overrideWith((ref) {
@@ -43,16 +44,13 @@ class _ScopedMapTimeline extends StatelessWidget {
throw Exception('User must be logged in to access archive'); throw Exception('User must be logged in to access archive');
} }
final withPartners = ref.watch(mapStateProvider.select((s) => s.withPartners)); final users = ref.watch(mapStateProvider).withPartners
final users = withPartners ? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id] : [user.id]; ? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id]
: [user.id];
final timelineService = ref final timelineService = ref
.watch(timelineFactoryProvider) .watch(timelineFactoryProvider)
.geographicMap( .map(users, ref.watch(mapStateProvider).toOptions());
users,
() => ref.read(mapStateProvider).toOptions(),
ref.read(mapStateProvider.notifier).optionsStream,
);
ref.onDispose(timelineService.dispose); ref.onDispose(timelineService.dispose);
return timelineService; return timelineService;
}), }),
@@ -297,7 +297,8 @@ class _AssetTypeIcons extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isLivePhoto = asset.isMotionPhoto; final remoteAsset = asset is RemoteAsset ? asset as RemoteAsset : null;
final isLivePhoto = remoteAsset?.livePhotoVideoId != null;
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -1,5 +1,3 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/models/events.model.dart';
@@ -65,16 +63,11 @@ class MapState {
class MapStateNotifier extends Notifier<MapState> { class MapStateNotifier extends Notifier<MapState> {
MapStateNotifier(); MapStateNotifier();
final StreamController<TimelineMapOptions> _optionsController = StreamController.broadcast();
Stream<TimelineMapOptions> get optionsStream => _optionsController.stream;
bool setBounds(LatLngBounds bounds) { bool setBounds(LatLngBounds bounds) {
if (state.bounds == bounds) { if (state.bounds == bounds) {
return false; return false;
} }
state = state.copyWith(bounds: bounds); state = state.copyWith(bounds: bounds);
_optionsController.add(state.toOptions());
return true; return true;
} }
@@ -89,14 +82,12 @@ class MapStateNotifier extends Notifier<MapState> {
void switchFavoriteOnly(bool isFavoriteOnly) { void switchFavoriteOnly(bool isFavoriteOnly) {
ref.read(settingsProvider).write(.mapShowFavoriteOnly, isFavoriteOnly); ref.read(settingsProvider).write(.mapShowFavoriteOnly, isFavoriteOnly);
state = state.copyWith(onlyFavorites: isFavoriteOnly); state = state.copyWith(onlyFavorites: isFavoriteOnly);
_optionsController.add(state.toOptions());
EventStream.shared.emit(const MapMarkerReloadEvent()); EventStream.shared.emit(const MapMarkerReloadEvent());
} }
void switchIncludeArchived(bool isIncludeArchived) { void switchIncludeArchived(bool isIncludeArchived) {
ref.read(settingsProvider).write(.mapIncludeArchived, isIncludeArchived); ref.read(settingsProvider).write(.mapIncludeArchived, isIncludeArchived);
state = state.copyWith(includeArchived: isIncludeArchived); state = state.copyWith(includeArchived: isIncludeArchived);
_optionsController.add(state.toOptions());
EventStream.shared.emit(const MapMarkerReloadEvent()); EventStream.shared.emit(const MapMarkerReloadEvent());
} }
@@ -109,13 +100,11 @@ class MapStateNotifier extends Notifier<MapState> {
void setRelativeTime(int relativeDays) { void setRelativeTime(int relativeDays) {
ref.read(settingsProvider).write(.mapRelativeDate, relativeDays); ref.read(settingsProvider).write(.mapRelativeDate, relativeDays);
state = state.copyWith(relativeDays: relativeDays); state = state.copyWith(relativeDays: relativeDays);
_optionsController.add(state.toOptions());
EventStream.shared.emit(const MapMarkerReloadEvent()); EventStream.shared.emit(const MapMarkerReloadEvent());
} }
@override @override
MapState build() { MapState build() {
ref.onDispose(_optionsController.close);
final mapConfig = ref.read(appConfigProvider.select((config) => config.map)); final mapConfig = ref.read(appConfigProvider.select((config) => config.map));
return MapState( return MapState(
themeMode: mapConfig.themeMode, themeMode: mapConfig.themeMode,
@@ -135,7 +135,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
ConsumerState createState() => _SliverTimelineState(); ConsumerState createState() => _SliverTimelineState();
} }
class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBindingObserver { class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
late final ScrollController _scrollController; late final ScrollController _scrollController;
StreamSubscription? _eventSubscription; StreamSubscription? _eventSubscription;
@@ -153,7 +153,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this);
_scrollController = ScrollController(onAttach: _restoreAssetPosition); _scrollController = ScrollController(onAttach: _restoreAssetPosition);
_eventSubscription = EventStream.shared.listen(_onEvent); _eventSubscription = EventStream.shared.listen(_onEvent);
@@ -179,14 +178,17 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
} }
} }
// Capture iOS status bar tap
@override
void handleStatusBarTap() => _scrollToTop();
void _onEvent(Event event) { void _onEvent(Event event) {
switch (event) { switch (event) {
case ScrollToTopEvent(): case ScrollToTopEvent():
_scrollToTop(); {
final timelineState = ref.read(timelineStateProvider.notifier);
timelineState.setScrubbing(true);
_scrollController
.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut)
.whenComplete(() => timelineState.setScrubbing(false));
}
case ScrollToDateEvent scrollToDateEvent: case ScrollToDateEvent scrollToDateEvent:
_scrollToDate(scrollToDateEvent.date); _scrollToDate(scrollToDateEvent.date);
case TimelineReloadEvent(): case TimelineReloadEvent():
@@ -244,24 +246,11 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this);
_scrollController.dispose(); _scrollController.dispose();
_eventSubscription?.cancel(); _eventSubscription?.cancel();
super.dispose(); super.dispose();
} }
void _scrollToTop() {
if (!_scrollController.hasClients) {
return;
}
final timelineState = ref.read(timelineStateProvider.notifier);
timelineState.setScrubbing(true);
_scrollController
.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut)
.whenComplete(() => timelineState.setScrubbing(false));
}
void _scrollToDate(DateTime date) { void _scrollToDate(DateTime date) {
final timelineState = ref.read(timelineStateProvider.notifier); final timelineState = ref.read(timelineStateProvider.notifier);
final asyncSegments = ref.read(timelineSegmentProvider); final asyncSegments = ref.read(timelineSegmentProvider);
@@ -392,9 +381,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
child: PrimaryScrollController( child: PrimaryScrollController(
controller: _scrollController, controller: _scrollController,
child: Scaffold( child: Scaffold(
// This removes the built in Scaffold `handleStatusBarTap` implementation, preventing duplicate
// events when we provide our own
primary: false,
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
floatingActionButton: const DownloadStatusFloatingButton(), floatingActionButton: const DownloadStatusFloatingButton(),
body: asyncSegments.widgetWhen( body: asyncSegments.widgetWhen(
@@ -10,6 +10,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart'; import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/services/asset.service.dart'; import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
@@ -27,6 +28,7 @@ import 'package:immich_mobile/services/download.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/utils/semver.dart'; import 'package:immich_mobile/utils/semver.dart';
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart'; import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -200,9 +202,24 @@ class ActionNotifier extends Notifier<void> {
} }
} }
Future<ActionResult> moveToLockFolder(ActionSource source) async { Future<ActionResult?> moveToLockFolder(ActionSource source, BuildContext context) async {
final ids = _getOwnedRemoteIdsForSource(source); final ids = _getOwnedRemoteIdsForSource(source);
final localIds = _getLocalIdsForSource(source, ignoreLocalOnly: true); final localIds = _getLocalIdsForSource(source, ignoreLocalOnly: true);
if (localIds.isNotEmpty) {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => ConfirmDialog(
title: "move_to_locked_folder",
content: CurrentPlatform.isAndroid ? "delete_dialog_alert_local" : "move_to_locked_folder_local_ios",
ok: "confirm",
),
);
if (confirmed != true) {
return null;
}
}
try { try {
await _service.moveToLockFolder(ids, localIds); await _service.moveToLockFolder(ids, localIds);
return ActionResult(count: ids.length, success: true); return ActionResult(count: ids.length, success: true);
@@ -45,9 +45,9 @@ class AssetMediaRepository {
return false; return false;
} }
Future<List<String>> deleteAll(List<String> ids) async { Future<List<String>> deleteAll(List<String> ids, {bool trash = true}) async {
if (CurrentPlatform.isAndroid) { if (CurrentPlatform.isAndroid) {
if (await _androidSupportsTrash()) { if (trash && await _androidSupportsTrash()) {
return PhotoManager.editor.android.moveToTrash( return PhotoManager.editor.android.moveToTrash(
ids.map((e) => AssetEntity(id: e, width: 1, height: 1, typeInt: 0)).toList(), ids.map((e) => AssetEntity(id: e, width: 1, height: 1, typeInt: 0)).toList(),
); );
+5 -5
View File
@@ -92,9 +92,9 @@ class ActionService {
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.locked); await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.locked);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked); await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked);
// Ask user if they want to delete local copies // Locked assets stay on the server, so permanently delete the local copies instead of trashing them
if (localIds.isNotEmpty) { if (localIds.isNotEmpty) {
await _deleteLocalAssets(localIds); await _deleteLocalAssets(localIds, trash: false);
} }
} }
@@ -313,12 +313,12 @@ class ActionService {
} }
} }
Future<int> _deleteLocalAssets(List<String> localIds) async { Future<int> _deleteLocalAssets(List<String> localIds, {bool trash = true}) async {
final deletedIds = await _assetMediaRepository.deleteAll(localIds); final deletedIds = await _assetMediaRepository.deleteAll(localIds, trash: trash);
if (deletedIds.isEmpty) { if (deletedIds.isEmpty) {
return 0; return 0;
} }
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) { if (trash && CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
await _trashedLocalAssetRepository.applyTrashedAssets(deletedIds); await _trashedLocalAssetRepository.applyTrashedAssets(deletedIds);
} else { } else {
await _localAssetRepository.delete(deletedIds); await _localAssetRepository.delete(deletedIds);
@@ -22,7 +22,6 @@ import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/debug_print.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart' as api;
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
final backgroundUploadServiceProvider = Provider((ref) { final backgroundUploadServiceProvider = Provider((ref) {
@@ -336,8 +335,7 @@ class BackgroundUploadService {
return null; return null;
} }
// Visibility hidden on upload to prevent the server from running regular jobs on the live photo asset final fields = {'livePhotoVideoId': livePhotoVideoId};
final fields = {'livePhotoVideoId': livePhotoVideoId, 'visibility': api.AssetVisibility.hidden.value};
final requiresWiFi = _shouldRequireWiFi(asset); final requiresWiFi = _shouldRequireWiFi(asset);
final originalFileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name; final originalFileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
@@ -4,7 +4,7 @@ import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.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/asset_metadata.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' hide AssetVisibility; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/network_capability_extensions.dart'; import 'package:immich_mobile/extensions/network_capability_extensions.dart';
@@ -19,7 +19,6 @@ import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart'; import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
@@ -339,8 +338,7 @@ class ForegroundUploadService {
final livePhotoResult = await _uploadRepository.uploadFile( final livePhotoResult = await _uploadRepository.uploadFile(
file: livePhotoFile, file: livePhotoFile,
originalFileName: livePhotoTitle, originalFileName: livePhotoTitle,
// Visibility hidden on upload to prevent the server from running regular jobs on the live photo asset fields: fields,
fields: {...fields, 'visibility': AssetVisibility.hidden.value},
cancelToken: cancelToken, cancelToken: cancelToken,
onProgress: onProgress != null onProgress: onProgress != null
? (bytes, totalBytes) => onProgress(asset.localId!, livePhotoTitle, bytes, totalBytes) ? (bytes, totalBytes) => onProgress(asset.localId!, livePhotoTitle, bytes, totalBytes)
@@ -28,8 +28,8 @@ class SystemConfigIntegrityChecksumJob {
/// Percentage limit of the integrity checksum job /// Percentage limit of the integrity checksum job
/// ///
/// Minimum value: 0 /// Minimum value: 0
/// Maximum value: 1 /// Maximum value: 9007199254740991
double percentageLimit; int percentageLimit;
/// How long the integrity checksum job may run for /// How long the integrity checksum job may run for
/// ///
@@ -75,7 +75,7 @@ class SystemConfigIntegrityChecksumJob {
return SystemConfigIntegrityChecksumJob( return SystemConfigIntegrityChecksumJob(
cronExpression: mapValueOfType<String>(json, r'cronExpression')!, cronExpression: mapValueOfType<String>(json, r'cronExpression')!,
enabled: mapValueOfType<bool>(json, r'enabled')!, enabled: mapValueOfType<bool>(json, r'enabled')!,
percentageLimit: mapValueOfType<double>(json, r'percentageLimit')!, percentageLimit: mapValueOfType<int>(json, r'percentageLimit')!,
timeLimit: mapValueOfType<int>(json, r'timeLimit')!, timeLimit: mapValueOfType<int>(json, r'timeLimit')!,
); );
} }
-4
View File
@@ -34,7 +34,6 @@ import 'schema_v27.dart' as v27;
import 'schema_v28.dart' as v28; import 'schema_v28.dart' as v28;
import 'schema_v29.dart' as v29; import 'schema_v29.dart' as v29;
import 'schema_v30.dart' as v30; import 'schema_v30.dart' as v30;
import 'schema_v31.dart' as v31;
class GeneratedHelper implements SchemaInstantiationHelper { class GeneratedHelper implements SchemaInstantiationHelper {
@override @override
@@ -100,8 +99,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v29.DatabaseAtV29(db); return v29.DatabaseAtV29(db);
case 30: case 30:
return v30.DatabaseAtV30(db); return v30.DatabaseAtV30(db);
case 31:
return v31.DatabaseAtV31(db);
default: default:
throw MissingSchemaException(version, versions); throw MissingSchemaException(version, versions);
} }
@@ -138,6 +135,5 @@ class GeneratedHelper implements SchemaInstantiationHelper {
28, 28,
29, 29,
30, 30,
31,
]; ];
} }
File diff suppressed because it is too large Load Diff
@@ -70,36 +70,4 @@ void main() {
expect((assets.first as RemoteAsset).id, asset.id); 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);
});
});
} }
+40 -6
View File
@@ -2,6 +2,8 @@ import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart'; import 'package:drift/native.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
@@ -147,13 +149,13 @@ void main() {
await Store.put(StoreKey.manageLocalMediaAndroid, true); await Store.put(StoreKey.manageLocalMediaAndroid, true);
const ids = ['a', 'b']; const ids = ['a', 'b'];
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids); when(() => assetMediaRepository.deleteAll(ids, trash: true)).thenAnswer((_) async => ids);
when(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).thenAnswer((_) async {}); when(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).thenAnswer((_) async {});
final result = await sut.deleteLocal(ids); final result = await sut.deleteLocal(ids);
expect(result, ids.length); expect(result, ids.length);
verify(() => assetMediaRepository.deleteAll(ids)).called(1); verify(() => assetMediaRepository.deleteAll(ids, trash: true)).called(1);
verify(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).called(1); verify(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).called(1);
verifyNever(() => localAssetRepository.delete(any())); verifyNever(() => localAssetRepository.delete(any()));
}); });
@@ -162,13 +164,13 @@ void main() {
await Store.put(StoreKey.manageLocalMediaAndroid, false); await Store.put(StoreKey.manageLocalMediaAndroid, false);
const ids = ['c']; const ids = ['c'];
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids); when(() => assetMediaRepository.deleteAll(ids, trash: true)).thenAnswer((_) async => ids);
when(() => localAssetRepository.delete(ids)).thenAnswer((_) async {}); when(() => localAssetRepository.delete(ids)).thenAnswer((_) async {});
final result = await sut.deleteLocal(ids); final result = await sut.deleteLocal(ids);
expect(result, ids.length); expect(result, ids.length);
verify(() => assetMediaRepository.deleteAll(ids)).called(1); verify(() => assetMediaRepository.deleteAll(ids, trash: true)).called(1);
verify(() => localAssetRepository.delete(ids)).called(1); verify(() => localAssetRepository.delete(ids)).called(1);
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any())); verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
}); });
@@ -177,14 +179,46 @@ void main() {
await Store.put(StoreKey.manageLocalMediaAndroid, true); await Store.put(StoreKey.manageLocalMediaAndroid, true);
const ids = ['x']; const ids = ['x'];
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => <String>[]); when(() => assetMediaRepository.deleteAll(ids, trash: true)).thenAnswer((_) async => <String>[]);
final result = await sut.deleteLocal(ids); final result = await sut.deleteLocal(ids);
expect(result, 0); expect(result, 0);
verify(() => assetMediaRepository.deleteAll(ids)).called(1); verify(() => assetMediaRepository.deleteAll(ids, trash: true)).called(1);
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any())); verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
verifyNever(() => localAssetRepository.delete(any())); verifyNever(() => localAssetRepository.delete(any()));
}); });
}); });
group('ActionService.moveToLockFolder', () {
const remoteIds = ['r1', 'r2'];
const localIds = ['l1', 'l2'];
test('permanently deletes local copies without trashing, even when Android trash handling is on', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
when(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.locked)).thenAnswer((_) async {});
when(() => remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {});
when(() => assetMediaRepository.deleteAll(localIds, trash: false)).thenAnswer((_) async => localIds);
when(() => localAssetRepository.delete(localIds)).thenAnswer((_) async {});
await sut.moveToLockFolder(remoteIds, localIds);
verify(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.locked)).called(1);
verify(() => remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked)).called(1);
verify(() => assetMediaRepository.deleteAll(localIds, trash: false)).called(1);
verify(() => localAssetRepository.delete(localIds)).called(1);
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
});
test('locks remote assets without touching local media when there are no local copies', () async {
when(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.locked)).thenAnswer((_) async {});
when(() => remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {});
await sut.moveToLockFolder(remoteIds, const []);
verify(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.locked)).called(1);
verifyNever(() => assetMediaRepository.deleteAll(any(), trash: any(named: 'trash')));
});
});
} }
@@ -140,7 +140,6 @@ void main() {
expect(task, isNotNull); expect(task, isNotNull);
expect(task!.fields['filename'], equals('OriginalLivePhoto.HEIC')); expect(task!.fields['filename'], equals('OriginalLivePhoto.HEIC'));
expect(task.fields['livePhotoVideoId'], equals('video-id-123')); expect(task.fields['livePhotoVideoId'], equals('video-id-123'));
expect(task.fields['visibility'], equals('hidden'));
verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1); verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1);
}); });
@@ -334,7 +333,6 @@ void main() {
expect(task, isNotNull); expect(task, isNotNull);
expect(task!.fields.containsKey('metadata'), isTrue); expect(task!.fields.containsKey('metadata'), isTrue);
expect(task.fields['livePhotoVideoId'], equals('video-123')); expect(task.fields['livePhotoVideoId'], equals('video-123'));
expect(task.fields['visibility'], equals('hidden'));
final metadata = jsonDecode(task.fields['metadata']!) as List; final metadata = jsonDecode(task.fields['metadata']!) as List;
expect(metadata, hasLength(1)); expect(metadata, hasLength(1));
+2 -3
View File
@@ -25917,10 +25917,9 @@
}, },
"percentageLimit": { "percentageLimit": {
"description": "Percentage limit of the integrity checksum job", "description": "Percentage limit of the integrity checksum job",
"format": "double", "maximum": 9007199254740991,
"maximum": 1,
"minimum": 0, "minimum": 0,
"type": "number" "type": "integer"
}, },
"timeLimit": { "timeLimit": {
"description": "How long the integrity checksum job may run for", "description": "How long the integrity checksum job may run for",
+1 -6
View File
@@ -74,12 +74,7 @@ const SystemConfigIntegrityJobSchema = z
const SystemConfigIntegrityChecksumJobSchema = SystemConfigIntegrityJobSchema.extend({ const SystemConfigIntegrityChecksumJobSchema = SystemConfigIntegrityJobSchema.extend({
timeLimit: z.int().nonnegative().describe('How long the integrity checksum job may run for'), timeLimit: z.int().nonnegative().describe('How long the integrity checksum job may run for'),
percentageLimit: z percentageLimit: z.int().nonnegative().describe('Percentage limit of the integrity checksum job'),
.float32()
.nonnegative()
.max(1)
.describe('Percentage limit of the integrity checksum job')
.meta({ format: 'double' }),
}) })
.describe('Integrity checksum job config') .describe('Integrity checksum job config')
.meta({ id: 'SystemConfigIntegrityChecksumJob' }); .meta({ id: 'SystemConfigIntegrityChecksumJob' });
+3 -2
View File
@@ -130,9 +130,10 @@ from
where where
"asset"."deletedAt" is null "asset"."deletedAt" is null
and "asset"."isExternal" = false and "asset"."isExternal" = false
and "asset"."createdAt" >= $2 and "integrity_report"."createdAt" >= $2
and "integrity_report"."createdAt" <= $3
order by order by
"asset"."createdAt" asc "integrity_report"."createdAt" asc
-- IntegrityRepository.streamIntegrityReports -- IntegrityRepository.streamIntegrityReports
select select
@@ -160,8 +160,8 @@ export class IntegrityRepository {
>; >;
} }
@GenerateSql({ params: [DummyValue.DATE], stream: true }) @GenerateSql({ params: [DummyValue.DATE, DummyValue.DATE], stream: true })
streamAssetChecksums(startMarker?: Date) { streamAssetChecksums(startMarker?: Date, endMarker?: Date) {
return this.db return this.db
.selectFrom('asset') .selectFrom('asset')
.where('asset.deletedAt', 'is', null) .where('asset.deletedAt', 'is', null)
@@ -178,8 +178,9 @@ export class IntegrityRepository {
'integrity_report.id as reportId', 'integrity_report.id as reportId',
]) ])
.where('asset.isExternal', '=', sql.lit(false)) .where('asset.isExternal', '=', sql.lit(false))
.$if(startMarker !== undefined, (qb) => qb.where('asset.createdAt', '>=', startMarker!)) .$if(startMarker !== undefined, (qb) => qb.where('integrity_report.createdAt', '>=', startMarker!))
.orderBy('asset.createdAt', 'asc') .$if(endMarker !== undefined, (qb) => qb.where('integrity_report.createdAt', '<=', endMarker!))
.orderBy('integrity_report.createdAt', 'asc')
.stream(); .stream();
} }
+1 -1
View File
@@ -426,7 +426,7 @@ export class AuthService extends BaseService {
throw new BadRequestException('This OAuth account has already been linked to another user.'); throw new BadRequestException('This OAuth account has already been linked to another user.');
} }
if (auth.session && sid) { if (auth.session) {
await this.sessionRepository.update(auth.session.id, { oauthSid: sid }); await this.sessionRepository.update(auth.session.id, { oauthSid: sid });
} }
+1 -67
View File
@@ -407,7 +407,7 @@ export class DatabaseBackupService {
progressCb?.('restore', progress); progressCb?.('restore', progress);
}); });
await pipeline(sqlStream, createSqlOwnerTransformStream(databaseUsername), progressSource, psql, progressSink); await pipeline(sqlStream, progressSource, psql, progressSink);
try { try {
progressCb?.('migrations', 0.9); progressCb?.('migrations', 0.9);
@@ -572,69 +572,3 @@ function createSqlProgressStreams(cb: (progress: number) => void) {
return [source, sink]; 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();
},
});
}
+22 -15
View File
@@ -532,7 +532,8 @@ export class IntegrityService extends BaseService {
const { count } = await this.integrityRepository.getAssetCount(); const { count } = await this.integrityRepository.getAssetCount();
const checkpoint = await this.systemMetadataRepository.get(SystemMetadataKey.IntegrityChecksumCheckpoint); const checkpoint = await this.systemMetadataRepository.get(SystemMetadataKey.IntegrityChecksumCheckpoint);
const startMarker = checkpoint?.date ? new Date(checkpoint.date) : undefined; let startMarker: Date | undefined = checkpoint?.date ? new Date(checkpoint.date) : undefined;
let endMarker: Date | undefined;
const printStats = () => { const printStats = () => {
const averageTime = ((Date.now() - startedAt) / processed).toFixed(2); const averageTime = ((Date.now() - startedAt) / processed).toFixed(2);
@@ -545,25 +546,31 @@ export class IntegrityService extends BaseService {
let lastCreatedAt: Date | undefined; let lastCreatedAt: Date | undefined;
this.logger.log(`Processing assets from ${startMarker?.toISOString() ?? 'beginning'}`); finishEarly: do {
this.logger.log(
`Processing assets in range [${startMarker?.toISOString() ?? 'beginning'}, ${endMarker?.toISOString() ?? 'end'}]`,
);
const assets = this.integrityRepository.streamAssetChecksums(startMarker); const assets = this.integrityRepository.streamAssetChecksums(startMarker, endMarker);
endMarker = startMarker;
startMarker = undefined;
for await (const { originalPath, checksum, createdAt, assetId, reportId } of assets) { for await (const { originalPath, checksum, createdAt, assetId, reportId } of assets) {
await this.checkAssetChecksum(originalPath, checksum, assetId, reportId); await this.checkAssetChecksum(originalPath, checksum, assetId, reportId);
processed++; processed++;
if (processed % 100 === 0) { if (processed % 100 === 0) {
printStats(); printStats();
}
if (Date.now() > startedAt + timeLimit || processed > count * percentageLimit) {
this.logger.log('Reached stop criteria.');
lastCreatedAt = createdAt;
break finishEarly;
}
} }
} while (endMarker);
if (Date.now() > startedAt + timeLimit || processed > count * percentageLimit) {
this.logger.log('Reached stop criteria.');
lastCreatedAt = createdAt;
break;
}
}
await this.systemMetadataRepository.set(SystemMetadataKey.IntegrityChecksumCheckpoint, { await this.systemMetadataRepository.set(SystemMetadataKey.IntegrityChecksumCheckpoint, {
date: lastCreatedAt?.toISOString(), date: lastCreatedAt?.toISOString(),
@@ -1,10 +1,9 @@
import { Kysely } from 'kysely'; import { Kysely } from 'kysely';
import { DateTime } from 'luxon';
import { createHash, randomUUID } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
import { Readable } from 'node:stream'; import { Readable } from 'node:stream';
import { text } from 'node:stream/consumers'; import { text } from 'node:stream/consumers';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { AssetFileType, IntegrityReport, JobName, JobStatus, SystemMetadataKey } from 'src/enum'; import { AssetFileType, IntegrityReport, JobName, JobStatus } from 'src/enum';
import { AssetRepository } from 'src/repositories/asset.repository'; import { AssetRepository } from 'src/repositories/asset.repository';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { EventRepository } from 'src/repositories/event.repository'; import { EventRepository } from 'src/repositories/event.repository';
@@ -703,30 +702,6 @@ describe(IntegrityService.name, () => {
ctx.get(IntegrityRepository).getIntegrityReport({ limit: 100 }, IntegrityReport.ChecksumFail), ctx.get(IntegrityRepository).getIntegrityReport({ limit: 100 }, IntegrityReport.ChecksumFail),
).resolves.toEqual({ items: [], nextCursor: undefined }); ).resolves.toEqual({ items: [], nextCursor: undefined });
}); });
it('should continue from checkpoint', async () => {
const { sut, ctx } = setup();
const job = ctx.getMock(JobRepository);
job.queue.mockResolvedValue();
const { user } = await ctx.newUser();
await ctx.newAsset({
ownerId: user.id,
createdAt: DateTime.now().minus({ days: 1 }).toISO(),
originalPath: '/foo/bar',
});
await ctx.newAsset({ ownerId: user.id, originalPath: '/foo/baz' });
await ctx
.get(SystemMetadataRepository)
.set(SystemMetadataKey.IntegrityChecksumCheckpoint, { date: DateTime.now().minus({ minutes: 5 }).toISO() });
await sut.handleChecksumFiles({ refreshOnly: false });
expect(ctx.getMock(StorageRepository).createPlainReadStream).not.toHaveBeenCalledWith('/foo/bar');
expect(ctx.getMock(StorageRepository).createPlainReadStream).toHaveBeenCalledWith('/foo/baz');
});
}); });
describe('handleChecksumRefresh', () => { describe('handleChecksumRefresh', () => {
@@ -15,12 +15,12 @@ import {
fromTimelinePlainDate, fromTimelinePlainDate,
fromTimelinePlainDateTime, fromTimelinePlainDateTime,
fromTimelinePlainYearMonth, fromTimelinePlainYearMonth,
fromISODateTimeUTCToObject,
getTimes, getTimes,
setDifference, setDifference,
type TimelineDateTime, type TimelineDateTime,
type TimelineYearMonth, type TimelineYearMonth,
getOrderingDate, getOrderingDate,
fromISODateTimeUTC,
} from '$lib/utils/timeline-util'; } from '$lib/utils/timeline-util';
import { GroupInsertionCache } from './group-insertion-cache.svelte'; import { GroupInsertionCache } from './group-insertion-cache.svelte';
import { TimelineDay } from './timeline-day.svelte'; import { TimelineDay } from './timeline-day.svelte';
@@ -190,7 +190,7 @@ export class TimelineMonth {
isVideo: !bucketAssets.isImage[i], isVideo: !bucketAssets.isImage[i],
livePhotoVideoId: bucketAssets.livePhotoVideoId[i], livePhotoVideoId: bucketAssets.livePhotoVideoId[i],
localDateTime, localDateTime,
createdAt: fromISODateTimeUTC(bucketAssets.createdAt[i]).toLocal().toObject(), createdAt: fromISODateTimeUTCToObject(bucketAssets.createdAt[i]),
fileCreatedAt, fileCreatedAt,
ownerId: bucketAssets.ownerId[i], ownerId: bucketAssets.ownerId[i],
projectionType: bucketAssets.projectionType[i], projectionType: bucketAssets.projectionType[i],