mirror of
https://github.com/immich-app/immich.git
synced 2026-03-01 11:20:12 +03:00
Compare commits
1 Commits
v2.2.2
...
fix-remote
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f72d47942 |
24
.github/workflows/build-mobile.yml
vendored
24
.github/workflows/build-mobile.yml
vendored
@@ -20,30 +20,6 @@ on:
|
||||
required: true
|
||||
ANDROID_STORE_PASSWORD:
|
||||
required: true
|
||||
APP_STORE_CONNECT_API_KEY_ID:
|
||||
required: true
|
||||
APP_STORE_CONNECT_API_KEY_ISSUER_ID:
|
||||
required: true
|
||||
APP_STORE_CONNECT_API_KEY:
|
||||
required: true
|
||||
IOS_CERTIFICATE_P12:
|
||||
required: true
|
||||
IOS_CERTIFICATE_PASSWORD:
|
||||
required: true
|
||||
IOS_PROVISIONING_PROFILE:
|
||||
required: true
|
||||
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION:
|
||||
required: true
|
||||
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION:
|
||||
required: true
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE:
|
||||
required: true
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION:
|
||||
required: true
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION:
|
||||
required: true
|
||||
FASTLANE_TEAM_ID:
|
||||
required: true
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
14
.github/workflows/prepare-release.yml
vendored
14
.github/workflows/prepare-release.yml
vendored
@@ -99,20 +99,6 @@ jobs:
|
||||
ALIAS: ${{ secrets.ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
|
||||
# iOS secrets
|
||||
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
||||
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
|
||||
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
|
||||
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
|
||||
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
||||
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
|
||||
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}
|
||||
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
||||
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
|
||||
|
||||
with:
|
||||
ref: ${{ needs.bump_version.outputs.ref }}
|
||||
environment: production
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "2.2.100",
|
||||
"version": "2.2.99",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"type": "module",
|
||||
"exports": "./dist/index.js",
|
||||
|
||||
4
docs/static/archived-versions.json
vendored
4
docs/static/archived-versions.json
vendored
@@ -1,8 +1,4 @@
|
||||
[
|
||||
{
|
||||
"label": "v2.2.2",
|
||||
"url": "https://docs.v2.2.2.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.2.1",
|
||||
"url": "https://docs.v2.2.1.archive.immich.app"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "2.2.2",
|
||||
"version": "2.2.1",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "immich-ml"
|
||||
version = "2.2.2"
|
||||
version = "2.2.1"
|
||||
description = ""
|
||||
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
|
||||
requires-python = ">=3.10,<4.0"
|
||||
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 3025,
|
||||
"android.injected.version.name" => "2.2.2",
|
||||
"android.injected.version.code" => 3024,
|
||||
"android.injected.version.name" => "2.2.1",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
@@ -169,7 +169,7 @@ platform :ios do
|
||||
targets: ["Runner", "ShareExtension", "WidgetExtension"]
|
||||
)
|
||||
increment_version_number(
|
||||
version_number: "2.2.2"
|
||||
version_number: "2.2.1"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -132,8 +132,7 @@ class SyncStreamService {
|
||||
return;
|
||||
// SyncCompleteV1 is used to signal the completion of the sync process. Cleanup stale assets and signal completion
|
||||
case SyncEntityType.syncCompleteV1:
|
||||
return;
|
||||
// return _syncStreamRepository.pruneAssets();
|
||||
return _syncStreamRepository.pruneAssets();
|
||||
// Request to reset the client state. Clear everything related to remote entities
|
||||
case SyncEntityType.syncResetV1:
|
||||
return _syncStreamRepository.reset();
|
||||
|
||||
@@ -612,12 +612,15 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||
|
||||
final validUsers = {currentUserId, ...partnerIds.nonNulls};
|
||||
|
||||
// Asset is not owned by the current user or any of their partners and is not part of any (shared) album
|
||||
// Asset is not owned by the current user or any of their partners and is not part of any (shared) album or memory
|
||||
// Likely a stale asset that was previously shared but has been removed
|
||||
await _db.remoteAssetEntity.deleteWhere((asset) {
|
||||
return asset.ownerId.isNotIn(validUsers) &
|
||||
asset.id.isNotInQuery(
|
||||
_db.remoteAlbumAssetEntity.selectOnly()..addColumns([_db.remoteAlbumAssetEntity.assetId]),
|
||||
) &
|
||||
asset.id.isNotInQuery(
|
||||
_db.memoryAssetEntity.selectOnly()..addColumns([_db.memoryAssetEntity.assetId]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
@@ -3,7 +3,7 @@ Immich API
|
||||
|
||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||
|
||||
- API version: 2.2.2
|
||||
- API version: 2.2.1
|
||||
- Generator version: 7.8.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 2.2.2+3025
|
||||
version: 2.2.1+3024
|
||||
|
||||
environment:
|
||||
sdk: '>=3.8.0 <4.0.0'
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
/// This test reproduces the bug where pruneAssets() deletes assets that are part of memories,
|
||||
/// causing foreign key constraint failures when trying to insert memory-asset relationships.
|
||||
void main() {
|
||||
late DbRepository db;
|
||||
late SyncStreamRepository sut;
|
||||
|
||||
setUp(() async {
|
||||
db = DbRepository(NativeDatabase.memory());
|
||||
sut = SyncStreamRepository(db);
|
||||
|
||||
// Set up test data: Create a user and a partner
|
||||
await sut.updateAuthUsersV1([
|
||||
SyncAuthUserV1(
|
||||
email: 'current-user@test.com',
|
||||
id: 'user-1',
|
||||
isAdmin: false,
|
||||
name: 'Current User',
|
||||
avatarColor: null,
|
||||
hasProfileImage: false,
|
||||
profileChangedAt: DateTime(2025),
|
||||
),
|
||||
]);
|
||||
|
||||
await sut.updateUsersV1([
|
||||
SyncUserV1(
|
||||
deletedAt: null,
|
||||
email: 'partner@test.com',
|
||||
id: 'partner-1',
|
||||
name: 'Partner User',
|
||||
avatarColor: null,
|
||||
hasProfileImage: false,
|
||||
profileChangedAt: DateTime(2025),
|
||||
),
|
||||
]);
|
||||
|
||||
await sut.updatePartnerV1([
|
||||
SyncPartnerV1(
|
||||
inTimeline: true,
|
||||
sharedById: 'partner-1',
|
||||
sharedWithId: 'user-1',
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
group('pruneAssets - Memory Asset Bug', () {
|
||||
test('BEFORE FIX: pruneAssets() should NOT delete assets that are part of memories', () async {
|
||||
// Step 1: Create an asset owned by someone else (not current user or partner)
|
||||
await sut.updateAssetsV1([
|
||||
SyncAssetV1(
|
||||
checksum: 'checksum-1'.codeUnits,
|
||||
deletedAt: null,
|
||||
deviceAssetId: 'device-1',
|
||||
deviceId: 'device-1',
|
||||
duplicateId: null,
|
||||
duration: null,
|
||||
fileCreatedAt: DateTime(2025, 1, 1),
|
||||
fileModifiedAt: DateTime(2025, 1, 1),
|
||||
id: 'asset-shared-memory',
|
||||
isArchived: false,
|
||||
isFavorite: false,
|
||||
isOffline: false,
|
||||
isTrashed: false,
|
||||
libraryId: null,
|
||||
livePhotoVideoId: null,
|
||||
localDateTime: DateTime(2025, 1, 1),
|
||||
originalFileName: 'shared-memory.jpg',
|
||||
// Asset owned by someone else - should be pruned if not in album/memory
|
||||
ownerId: 'other-user-not-partner',
|
||||
resized: true,
|
||||
stackId: null,
|
||||
thumbhash: null,
|
||||
type: AssetTypeEnum.IMAGE,
|
||||
updatedAt: DateTime(2025, 1, 1),
|
||||
visibility: AssetVisibility.public_,
|
||||
),
|
||||
]);
|
||||
|
||||
// Step 2: Create a memory owned by current user
|
||||
await sut.updateMemoriesV1([
|
||||
SyncMemoryV1(
|
||||
createdAt: DateTime(2025, 1, 1),
|
||||
data: {'year': 2025, 'title': 'Test Memory'},
|
||||
deletedAt: null,
|
||||
hideAt: null,
|
||||
id: 'memory-1',
|
||||
isSaved: false,
|
||||
memoryAt: DateTime(2025, 1, 1),
|
||||
ownerId: 'user-1',
|
||||
seenAt: null,
|
||||
showAt: DateTime(2025, 1, 1),
|
||||
type: MemoryType.onThisDay,
|
||||
updatedAt: DateTime(2025, 1, 1),
|
||||
),
|
||||
]);
|
||||
|
||||
// Step 3: Link the shared asset to the memory
|
||||
await sut.updateMemoryAssetsV1([
|
||||
SyncMemoryAssetV1(
|
||||
assetId: 'asset-shared-memory',
|
||||
memoryId: 'memory-1',
|
||||
),
|
||||
]);
|
||||
|
||||
// Verify the asset and memory-asset relationship exist
|
||||
final assetsBefore = await db.remoteAssetEntity.select().get();
|
||||
final memoryAssetsBefore = await db.memoryAssetEntity.select().get();
|
||||
expect(assetsBefore.length, 1);
|
||||
expect(assetsBefore.first.id, 'asset-shared-memory');
|
||||
expect(memoryAssetsBefore.length, 1);
|
||||
|
||||
// Step 4: Call pruneAssets() - This is where the bug happens
|
||||
await sut.pruneAssets();
|
||||
|
||||
// Step 5: Verify the asset is NOT deleted (because it's in a memory)
|
||||
final assetsAfter = await db.remoteAssetEntity.select().get();
|
||||
expect(
|
||||
assetsAfter.length,
|
||||
1,
|
||||
reason: 'Asset should NOT be pruned because it is part of a memory',
|
||||
);
|
||||
expect(assetsAfter.first.id, 'asset-shared-memory');
|
||||
|
||||
// Step 6: Verify we can still work with memory-asset relationships
|
||||
// This simulates receiving more sync events after pruning
|
||||
await expectLater(
|
||||
sut.updateMemoryAssetsV1([
|
||||
SyncMemoryAssetV1(
|
||||
assetId: 'asset-shared-memory',
|
||||
memoryId: 'memory-1',
|
||||
),
|
||||
]),
|
||||
completes,
|
||||
reason: 'Should not throw foreign key constraint error',
|
||||
);
|
||||
});
|
||||
|
||||
test('pruneAssets() SHOULD delete assets not in albums or memories', () async {
|
||||
// Step 1: Create an asset that's truly orphaned (not in album or memory)
|
||||
await sut.updateAssetsV1([
|
||||
SyncAssetV1(
|
||||
checksum: 'checksum-2'.codeUnits,
|
||||
deletedAt: null,
|
||||
deviceAssetId: 'device-2',
|
||||
deviceId: 'device-2',
|
||||
duplicateId: null,
|
||||
duration: null,
|
||||
fileCreatedAt: DateTime(2025, 1, 1),
|
||||
fileModifiedAt: DateTime(2025, 1, 1),
|
||||
id: 'asset-orphaned',
|
||||
isArchived: false,
|
||||
isFavorite: false,
|
||||
isOffline: false,
|
||||
isTrashed: false,
|
||||
libraryId: null,
|
||||
livePhotoVideoId: null,
|
||||
localDateTime: DateTime(2025, 1, 1),
|
||||
originalFileName: 'orphaned.jpg',
|
||||
ownerId: 'other-user-not-partner',
|
||||
resized: true,
|
||||
stackId: null,
|
||||
thumbhash: null,
|
||||
type: AssetTypeEnum.IMAGE,
|
||||
updatedAt: DateTime(2025, 1, 1),
|
||||
visibility: AssetVisibility.public_,
|
||||
),
|
||||
]);
|
||||
|
||||
// Verify the asset exists
|
||||
final assetsBefore = await db.remoteAssetEntity.select().get();
|
||||
expect(assetsBefore.length, 1);
|
||||
|
||||
// Call pruneAssets()
|
||||
await sut.pruneAssets();
|
||||
|
||||
// Verify the orphaned asset IS deleted
|
||||
final assetsAfter = await db.remoteAssetEntity.select().get();
|
||||
expect(
|
||||
assetsAfter.length,
|
||||
0,
|
||||
reason: 'Orphaned asset should be pruned',
|
||||
);
|
||||
});
|
||||
|
||||
test('pruneAssets() should NOT delete assets in albums', () async {
|
||||
// Step 1: Create an asset and an album
|
||||
await sut.updateAssetsV1([
|
||||
SyncAssetV1(
|
||||
checksum: 'checksum-3'.codeUnits,
|
||||
deletedAt: null,
|
||||
deviceAssetId: 'device-3',
|
||||
deviceId: 'device-3',
|
||||
duplicateId: null,
|
||||
duration: null,
|
||||
fileCreatedAt: DateTime(2025, 1, 1),
|
||||
fileModifiedAt: DateTime(2025, 1, 1),
|
||||
id: 'asset-in-album',
|
||||
isArchived: false,
|
||||
isFavorite: false,
|
||||
isOffline: false,
|
||||
isTrashed: false,
|
||||
libraryId: null,
|
||||
livePhotoVideoId: null,
|
||||
localDateTime: DateTime(2025, 1, 1),
|
||||
originalFileName: 'in-album.jpg',
|
||||
ownerId: 'other-user-not-partner',
|
||||
resized: true,
|
||||
stackId: null,
|
||||
thumbhash: null,
|
||||
type: AssetTypeEnum.IMAGE,
|
||||
updatedAt: DateTime(2025, 1, 1),
|
||||
visibility: AssetVisibility.public_,
|
||||
),
|
||||
]);
|
||||
|
||||
await sut.updateAlbumsV1([
|
||||
SyncAlbumV1(
|
||||
albumName: 'Test Album',
|
||||
albumThumbnailAssetId: null,
|
||||
createdAt: DateTime(2025, 1, 1),
|
||||
deletedAt: null,
|
||||
description: 'Test',
|
||||
id: 'album-1',
|
||||
isActivityEnabled: false,
|
||||
lastModifiedAssetTimestamp: DateTime(2025, 1, 1),
|
||||
order: AlbumUserRole.editor,
|
||||
ownerId: 'user-1',
|
||||
startDate: DateTime(2025, 1, 1),
|
||||
endDate: DateTime(2025, 1, 2),
|
||||
updatedAt: DateTime(2025, 1, 1),
|
||||
),
|
||||
]);
|
||||
|
||||
await sut.updateAlbumToAssetsV1([
|
||||
SyncAlbumToAssetV1(
|
||||
albumId: 'album-1',
|
||||
assetId: 'asset-in-album',
|
||||
),
|
||||
]);
|
||||
|
||||
// Verify setup
|
||||
final assetsBefore = await db.remoteAssetEntity.select().get();
|
||||
expect(assetsBefore.length, 1);
|
||||
|
||||
// Call pruneAssets()
|
||||
await sut.pruneAssets();
|
||||
|
||||
// Verify asset is NOT deleted (protected by album membership)
|
||||
final assetsAfter = await db.remoteAssetEntity.select().get();
|
||||
expect(
|
||||
assetsAfter.length,
|
||||
1,
|
||||
reason: 'Asset should NOT be pruned because it is in an album',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -10006,7 +10006,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "2.2.2",
|
||||
"version": "2.2.1",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "2.2.2",
|
||||
"version": "2.2.1",
|
||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||
"type": "module",
|
||||
"main": "./build/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Immich
|
||||
* 2.2.2
|
||||
* 2.2.1
|
||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||
* See https://www.npmjs.com/package/oazapfts
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "2.2.2",
|
||||
"version": "2.2.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "2.2.2",
|
||||
"version": "2.2.1",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user