Compare commits

...

253 Commits

Author SHA1 Message Date
shenlong-tanwen
c954d8121f merge main 2026-04-28 22:29:15 +07:00
renovate[bot]
94bb6c1a5e chore(deps): update dependency @immich/ui to v0.76.2 (#28121)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-27 21:54:12 -05:00
Daniel Dietzler
fe9e5afcf4 fix: do not emit AlbumInvite event for owner (#28110) 2026-04-27 17:59:46 +00:00
Yosi Taguri
5e89efba64 fix(ml): handle empty/corrupt images in face detection (#27391)
* fix(ml): handle empty/corrupt images in face detection

When a corrupt or degenerate image with zero-dimension (0 width or 0 height)
reaches the face detection pipeline, insightface's RetinaFace.detect() calls
cv2.resize() with a target size of 0, triggering an OpenCV assertion failure:

  error: (-215:Assertion failed) inv_scale_x > 0 in function 'resize'

This crashes the ML worker and returns a 500 error to the server.

Add an early return in FaceDetector._predict() that checks for zero-dimension
images after decoding and returns empty detection results instead of passing
them to the insightface model.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ml): move empty image validation to request level

Per review feedback, validate image dimensions in the predict endpoint
(returning 400) rather than in each model's _predict method. This
catches all zero-dimension images before they reach any model task.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ml): resolve mypy strict type error in predict endpoint

Use intermediate `decoded` variable so mypy knows `.width` and `.height`
are accessed on `Image`, not on `Image | str`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 11:14:34 -04:00
Peter Ombodi
5a457d72c9 fix(mobile): delete assets on trash empty, Android (#26070)
* fix(mobile): improve trash sync flow
- trash local assets on remote delete events
- unify remote trash handling and support assetDelete cleanup by remote asset id
- update sync stream tests

* fix(mobile): revert pubspec.lock

* refactor(mobile): remove helper
remove unused columns from results

* refactor(mobile): use remoteIds in getAssetsFromBackupAlbums and remove getAssetsFromBackupAlbumsByRemoteIds
refactor tests

---------

Co-authored-by: Peter Ombodi <peter.ombodi@gmail.com>
2026-04-27 18:46:49 +05:30
Min Idzelis
45ccdb37fb refactor(web): replace asset-viewer listener based face hover with overlay elements (#27400) 2026-04-27 12:08:34 +02:00
Savely Krasovsky
9263e2f2e1 feat(ml): update Intel graphics compiler and compute runtime (#28076)
feat(ml): update Intel graphics compiler and compute runtime to latest versions
2026-04-25 08:49:57 -04:00
Aaron Liu
a3ee615c5b chore(ml): update huggingfacehub and pillow (#27552) 2026-04-24 19:44:01 -04:00
Yaros
39cfad7136 feat(mobile): action bottom sheet on map timeline (#27515) 2026-04-24 09:30:10 -05:00
renovate[bot]
350056dd1a fix(deps): update dependency uuid to v14 [security] (#28046)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-23 11:24:33 +02:00
Alex
f0835d06f8 chore: migrate to FUTO Apple's account (#28020)
* chore: migrate to FUTO Apple's account

* chore: migrate to FUTO Apple's account

* chore: match widget and share extension

* chore: update app share group

* reuse group.app.immich.share
2026-04-22 11:53:20 -05:00
Peter Ombodi
a7d493bb65 style(mobile): format code #2 2026-04-14 17:18:32 +03:00
Peter Ombodi
a3493c29cf style(mobile): format code 2026-04-14 16:46:01 +03:00
Peter Ombodi
3be47421eb fix(mobile): organize imports 2026-04-14 16:20:12 +03:00
Peter Ombodi
7a2bf46895 fix(mobile): resolve merge conflicts 2026-04-14 14:29:07 +03:00
Peter Ombodi
1e100bcddf Merge remote-tracking branch 'public/feat/mobile-review-1' into feature/out-of-sync_assets_v2_step2
# Conflicts:
#	mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart
2026-04-14 13:06:52 +03:00
shenlong-tanwen
6524230158 Merge branch 'main' into fear/mobile-review-1 2026-04-13 18:33:21 +05:30
Peter Ombodi
1b1fd38372 fix(mobile): resolve merge conflicts
restore part of code removed from Step 1 PR
2026-02-26 17:13:50 +02:00
Peter Ombodi
10fdf671da Merge branch 'feature/out-of-sync_assets_v2' into feature/out-of-sync_assets_v2_step2
# Conflicts:
#	mobile/lib/infrastructure/repositories/trash_sync.repository.dart
#	mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart
#	mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart
2026-02-26 13:39:01 +02:00
Peter Ombodi
cba5f5f92e fix(mobile): fix test init 2026-02-24 19:13:21 +02:00
Peter Ombodi
535ca98c23 refactor(mobile): resolve merge conflicts
refactor ActionButtonContext
2026-02-24 18:55:36 +02:00
Peter Ombodi
bf3ece5d4b Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2
# Conflicts:
#	mobile/drift_schemas/main/drift_schema_v20.json
#	mobile/lib/infrastructure/repositories/db.repository.dart
#	mobile/lib/infrastructure/repositories/db.repository.steps.dart
#	mobile/test/domain/services/sync_stream_service_test.dart
#	mobile/test/drift/main/generated/schema_v20.dart
2026-02-24 17:51:07 +02:00
Peter Ombodi
00ad407381 chore(mobile): resolve merge conflicts 2026-02-17 17:49:31 +02:00
Peter Ombodi
9f70d6eb12 Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2
# Conflicts:
#	mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart
#	mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart
2026-02-17 17:43:59 +02:00
Peter Ombodi
a7431793af chore(mobile): fix format 2026-02-16 13:32:28 +02:00
Peter Ombodi
23ffe936da Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2 2026-02-16 12:18:38 +02:00
Peter Ombodi
181717f1c3 refactor(mobile): remove code related to deleteOutdated 2026-02-16 12:16:19 +02:00
Peter Ombodi
f2f0c91aa8 chore(mobile): address review comments #1 2026-02-16 11:58:09 +02:00
Peter Ombodi
4c5ca8016d chore(mobile): regenerate db.repository.drift.dart 2026-02-13 19:16:57 +02:00
Peter Ombodi
3ffe0b1e22 chore(mobile): resolve DB merge conflicts, bump version, and regenerate schemas 2026-02-13 19:04:16 +02:00
Peter Ombodi
84a4f689ca Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2
# Conflicts:
#	mobile/drift_schemas/main/drift_schema_v19.json
#	mobile/lib/infrastructure/repositories/db.repository.dart
#	mobile/lib/infrastructure/repositories/db.repository.drift.dart
#	mobile/lib/infrastructure/repositories/db.repository.steps.dart
#	mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart
#	mobile/test/drift/main/generated/schema_v19.dart
2026-02-13 18:48:41 +02:00
Peter Ombodi
901ae69a6c Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2
# Conflicts:
#	mobile/lib/widgets/common/immich_sliver_app_bar.dart
2026-02-12 11:12:17 +02:00
Peter Ombodi
d78bf504f1 Merge branch 'feature/out-of-sync_assets_v2' into feature/out-of-sync_assets_v2_step2
# Conflicts:
#	mobile/lib/infrastructure/repositories/local_asset.repository.dart
2026-01-30 16:07:07 +02:00
Peter Ombodi
931a70402e Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2 2026-01-30 11:19:46 +02:00
Peter Ombodi
24797d31a8 Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2
# Conflicts:
#	mobile/lib/domain/models/store.model.dart
#	mobile/test/domain/services/sync_stream_service_test.dart
2026-01-29 12:37:30 +02:00
Peter Ombodi
1088b7be3f Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2 2026-01-28 15:26:51 +02:00
Peter Ombodi
c11e80c0f6 Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2 2026-01-28 13:50:03 +02:00
Peter Ombodi
ed3f5c96b1 refactor(mobile): move getToTrash to local asset repo and update tests 2026-01-28 13:48:40 +02:00
Peter Ombodi
cbf699653b Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2_step2 2026-01-28 12:40:08 +02:00
Peter Ombodi
0c9a23fb4d fix(mobile): resolve merge conflicts
update getRemoteTrashedLocalAssets according to updated models
fix tests
refactor code
2026-01-28 12:37:27 +02:00
Peter Ombodi
74a2532153 Merge branch 'feature/out-of-sync_assets_v2' into feature/out-of-sync_assets_v2_step2
# Conflicts:
#	mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart
#	mobile/lib/presentation/widgets/action_buttons/move_to_trash_action_button.widget.dart
#	mobile/test/domain/services/local_sync_service_test.dart
#	mobile/test/domain/services/sync_stream_service_test.dart
2026-01-27 19:15:56 +02:00
Peter Ombodi
42835f3368 fix(mobile): format code 2026-01-27 15:53:46 +02:00
Peter Ombodi
6bbfa0d107 fix(mobile): format code 2026-01-27 15:42:10 +02:00
Peter Ombodi
0faccff0c0 Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2 2026-01-27 14:26:57 +02:00
Peter Ombodi
82b34124a2 refactor(mobile): introduce RemoteDeletedLocalAsset model
- move remote deletion timestamp off LocalAsset
- use RemoteDeletedLocalAsset in trash/sync repositories and services
- update drift schema artifacts and related tests
2026-01-27 14:25:09 +02:00
Peter Ombodi
ec88985de6 fix(mobile): resolve merge conflicts 2026-01-26 20:03:34 +02:00
Peter Ombodi
4e836ee33c Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2
# Conflicts:
#	mobile/drift_schemas/main/drift_schema_v18.json
#	mobile/lib/infrastructure/repositories/db.repository.dart
#	mobile/lib/infrastructure/repositories/db.repository.drift.dart
#	mobile/lib/infrastructure/repositories/db.repository.steps.dart
#	mobile/test/drift/main/generated/schema_v18.dart
2026-01-26 19:39:53 +02:00
Peter Ombodi
85590076db refactor(mobile): refactor code 2026-01-26 16:11:28 +02:00
Peter Ombodi
511859ed16 fix(mobile): fix tests 2026-01-26 15:58:21 +02:00
Peter Ombodi
4aa9911a89 Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2 2026-01-26 15:45:37 +02:00
Peter Ombodi
76c3442ba1 fix(mobile): address review feedback, p#3
remove code related to review logic
2026-01-26 15:44:39 +02:00
Peter Ombodi
412c462151 Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2
# Conflicts:
#	i18n/en.json
#	mobile/lib/domain/models/store.model.dart
#	mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart
#	mobile/lib/widgets/settings/advanced_settings.dart
2026-01-26 12:59:56 +02:00
Peter Ombodi
078ac1b59e Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2 2026-01-23 16:23:58 +02:00
Peter Ombodi
2e7ea5a466 fix(mobile): address review feedback, p#2
use deleteOutdatedThrottled instead deleteOutdated
refactor var names
2026-01-23 15:39:15 +02:00
Peter Ombodi
7495e8df83 fix(mobile): regenerate db.repository.drift.dart 2026-01-22 18:47:23 +02:00
Peter Ombodi
108a2008ae refactor(mobile): address review feedback 2026-01-22 18:35:04 +02:00
Peter Ombodi
9f85137eb3 Merge branch 'feature/out-of-sync_assets_v2' into feature/out-of-sync_assets_v2_step2 2026-01-21 15:59:47 +02:00
Peter Ombodi
91c0ce8db2 Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2 2026-01-21 15:48:58 +02:00
Peter Ombodi
c246a27384 feature(mobile): Resolve merge conflicts 2026-01-20 20:29:00 +02:00
Peter Ombodi
64fe72c15f Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2
# Conflicts:
#	mobile/drift_schemas/main/drift_schema_v17.json
#	mobile/lib/domain/models/asset/local_asset.model.dart
#	mobile/lib/domain/models/asset/remote_asset.model.dart
#	mobile/lib/domain/services/sync_stream.service.dart
#	mobile/lib/infrastructure/repositories/db.repository.dart
#	mobile/lib/infrastructure/repositories/db.repository.steps.dart
#	mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart
#	mobile/test/drift/main/generated/schema_v17.dart
2026-01-20 20:13:41 +02:00
Peter Ombodi
899d4807f5 feature(mobile): Optimize resolveRemoteTrash
Change toast message
Refactor code
2026-01-19 19:11:36 +02:00
Peter Ombodi
cc7e8780ed feature(mobile): Rework resolveRemoteTrash
Update related tests
Refactor code
2026-01-16 19:35:34 +02:00
Peter Ombodi
19e802627e feature(mobile): Add resolveRemoteTrash flow with storage lookup and approvals
Add resolveRemoteTrash tests
2026-01-15 12:23:47 +02:00
Peter Ombodi
97fcae32e1 fix(mobile): resolve merge conflicts 2026-01-15 10:06:51 +02:00
Peter Ombodi
b76e2f7337 Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2
# Conflicts:
#	mobile/drift_schemas/main/drift_schema_v16.json
#	mobile/lib/infrastructure/repositories/db.repository.dart
#	mobile/lib/infrastructure/repositories/db.repository.drift.dart
#	mobile/lib/infrastructure/repositories/db.repository.steps.dart
#	mobile/test/drift/main/generated/schema_v16.dart
2026-01-15 09:59:13 +02:00
Peter Ombodi
e8c5ce3e7d fix(mobile): fix format 2026-01-14 14:15:01 +02:00
Peter Ombodi
81e268c2b9 fix(mobile): regenerate files 2026-01-14 13:57:54 +02:00
Peter Ombodi
c0d9554461 fix(mobile): resolve merge conflicts 2026-01-14 13:32:38 +02:00
Peter Ombodi
0287d40dc0 Merge remote-tracking branch 'public/main' into feature/out-of-sync_assets_v2
# Conflicts:
#	mobile/drift_schemas/main/drift_schema_v15.json
#	mobile/lib/infrastructure/repositories/db.repository.dart
#	mobile/lib/infrastructure/repositories/db.repository.steps.dart
#	mobile/test/drift/main/generated/schema_v15.dart
2026-01-14 13:15:26 +02:00
Peter Ombodi
f18d14fb23 refactor(mobile): split PR changes, remove code not related to showing review timeline 2026-01-14 12:21:48 +02:00
Peter Ombodi
1cee25292b Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2 2026-01-09 17:37:45 +02:00
Peter Ombodi
20c985e547 test(mobile): add trash review flow tests 2026-01-09 17:35:46 +02:00
Peter Ombodi
a33fdb61f6 fix(mobile): remove doubled call 2026-01-09 14:41:02 +02:00
Peter Ombodi
cddb749ffb fix(mobile): fix&refactor logic in resolveRemoteTrash 2026-01-09 14:35:59 +02:00
Peter Ombodi
d8d3fe88da fix(mobile): improve deleting dialog
fix label style
2026-01-08 17:34:35 +02:00
Peter Ombodi
1b0322c9d8 fix(mobile): Set warning colors via ThemeData colorScheme.tertiary
Use colorScheme.tertiary for warning button color
2026-01-08 16:19:59 +02:00
Peter Ombodi
8cb970484f Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2
# Conflicts:
#	i18n/en.json
#	mobile/lib/infrastructure/repositories/local_asset.repository.dart
2026-01-08 12:41:04 +02:00
Peter Ombodi
d0183b0015 Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2 2026-01-05 16:03:02 +02:00
Peter Ombodi
3661703089 feat(mobile): Refactor trash-sync action buttons to use result callbacks and update viewer refresh/toast handling
Fix controls flickering during asset/timeline updates
Update keep result text
2026-01-05 15:02:26 +02:00
Peter Ombodi
b40082d02e Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2 2026-01-02 15:14:48 +02:00
Peter Ombodi
b4d5d4cafd Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2 2025-12-26 17:08:52 +02:00
Peter Ombodi
76135454bc Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2 2025-12-23 09:42:10 +02:00
Peter Ombodi
b8bc0feec2 Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2 2025-12-22 15:40:05 +02:00
Peter Ombodi
dc27d6323e Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2 2025-12-19 12:35:55 +02:00
Peter Ombodi
30139d13f2 Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2 2025-12-17 18:44:17 +02:00
Peter Ombodi
45182e385d fix(trash_sync_review): resolve merge conflicts 2025-12-17 18:41:46 +02:00
Peter Ombodi
0db44050e0 Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2
# Conflicts:
#	mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart
#	mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart
#	mobile/lib/utils/action_button.utils.dart
2025-12-17 11:29:45 +02:00
Peter Ombodi
f8073e32fd Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2 2025-12-12 18:24:52 +02:00
Peter Ombodi
83fd41a8b7 Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2 2025-12-11 14:14:33 +02:00
Peter Ombodi
29336166b9 feat(trash_sync_review): resolve merge conflicts 2025-12-11 14:12:16 +02:00
Peter Ombodi
d2796eb6f6 Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2
# Conflicts:
#	mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart
2025-12-10 11:03:34 +02:00
Peter Ombodi
35fc21f913 Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2
# Conflicts:
#	mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart
2025-12-09 13:09:54 +02:00
Peter Ombodi
929c71fdca Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2
# Conflicts:
#	mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart
2025-12-08 09:22:21 +02:00
Peter Ombodi
1f11c0dddf feat(trash_sync_review): fix for edge case 2025-12-04 12:53:15 +02:00
Peter Ombodi
0497ad36dc feat(trash_sync_review): resolve merge conflicts 2025-12-04 11:06:10 +02:00
Peter Ombodi
878396d325 Merge remote-tracking branch 'refs/remotes/origin/main' into feature/out-of-sync_assets_v2
# Conflicts:
#	mobile/drift_schemas/main/drift_schema_v14.json
#	mobile/lib/domain/models/asset/local_asset.model.dart
#	mobile/lib/infrastructure/repositories/db.repository.dart
#	mobile/lib/infrastructure/repositories/db.repository.steps.dart
#	mobile/test/drift/main/generated/schema_v14.dart
2025-12-04 10:52:44 +02:00
Peter Ombodi
217d5b3b1b feat(trash_sync_review): increase timeline update delay 2025-12-03 18:31:18 +02:00
Peter Ombodi
ce128d044d Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2 2025-12-03 18:06:28 +02:00
Peter Ombodi
5da8cc6a40 Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2 2025-12-02 18:00:50 +02:00
Peter Ombodi
5cbe68bd5c feat(trash_sync_review): resolve merge conflicts 2025-12-01 17:44:21 +02:00
Peter Ombodi
f2108cc0d0 Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2 2025-12-01 17:24:47 +02:00
Peter Ombodi
d7385cad8e Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2 2025-11-25 15:21:59 +02:00
Peter Ombodi
06c882fe86 feat(trash_sync_review): add review actions on asset details bottom-sheet
refactor code
2025-11-25 13:08:34 +02:00
Peter Ombodi
cf78b48386 Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2 2025-11-25 12:37:27 +02:00
Peter Ombodi
11fcef33fd feat(trash_sync_review): remove unused methods and params
refactor code
2025-11-24 14:10:50 +02:00
Peter Ombodi
0a2d03dfe2 feat(trash_sync_review): fix format 2025-11-24 12:19:13 +02:00
Peter Ombodi
c97c481892 feat(trash_sync_review): remove unnecessary column from table structure
refactor related code
2025-11-24 12:06:51 +02:00
Peter Ombodi
3c2439af46 Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2 2025-11-21 15:46:33 +02:00
Peter Ombodi
e758498727 feat(trash_sync_review): fix review in time-line (fetch, update on review)
fix unSync icon
fix format
refactor code
2025-11-21 15:42:03 +02:00
Peter Ombodi
5c22617a6b Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2 2025-11-20 11:26:28 +02:00
Peter Ombodi
eeed924c42 feat(trash_sync_review): fix tests
fix format
2025-11-19 18:08:57 +02:00
Peter Ombodi
6ae696904c Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2 2025-11-19 17:44:23 +02:00
Peter Ombodi
df50d92eeb fix(trash_sync_review): fix format 2025-11-19 17:41:16 +02:00
Peter Ombodi
17533b079c feat(trash_sync_review): remove accidental changes 2025-11-19 17:31:51 +02:00
Peter Ombodi
7064bb2e24 feat(trash_sync_review): fix raw sql 2025-11-19 13:33:52 +02:00
Peter Ombodi
7d6bc12377 feat(trash_sync_review): use radioButtons instead toggles in AdvancedSettings
add subtitle to SettingsRadioGroup
more clear labels
refactor code
2025-11-19 12:00:42 +02:00
Peter Ombodi
fe5d24cb43 feat(trash_sync_review): fix migration
fix watchIsApprovalPending
refactor code
show out-of-sync status (experimental)
2025-11-18 17:22:57 +02:00
Peter Ombodi
9ff5aadb2d feat(trash_sync_review): cleunup trash_sync repo
add remoteDeletedAt in LocalAsset
optimize trash_sync table
use remote.deletedAt for resolve conflicts
refactor code
2025-11-17 18:48:43 +02:00
Peter Ombodi
932af903b4 Merge remote-tracking branch 'public/main' into feature/out-of-sync_assets_v2
# Conflicts:
#	mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart
2025-11-17 10:16:58 +02:00
Peter Ombodi
d7510bb50b Merge remote-tracking branch 'public/main' into feature/out-of-sync_assets_v2 2025-11-14 16:12:35 +02:00
Peter Ombodi
21d398035d feat(trash_sync_review): polish review flow
path deletedAt to LocalAsset
fix mergedBucket date
2025-11-14 15:07:26 +02:00
Peter Ombodi
2091c5d3e2 Merge remote-tracking branch 'public/main' into feature/out-of-sync_assets_v2 2025-11-13 11:28:55 +02:00
Peter Ombodi
d197de8eca feat(trash_sync_review): group sync trash timeline by checksum
include in merged assets deleted assets presented as local
show next asset on users decision
use diff design for action buttons
hide some widgets in sync mode
2025-11-12 19:32:18 +02:00
Peter Ombodi
cd27f2da9c feat(trash_sync_review): fix group by date 2025-11-11 16:34:07 +02:00
Peter Ombodi
fd8ba5ca16 feat(trash_sync_review): resolve merge conflicts 2025-11-11 16:21:05 +02:00
Peter Ombodi
cdb66e7a08 Merge remote-tracking branch 'public/main' into feature/out-of-sync_assets_v2
# Conflicts:
#	mobile/lib/domain/services/local_sync.service.dart
#	mobile/lib/domain/services/sync_stream.service.dart
#	mobile/lib/infrastructure/repositories/db.repository.dart
#	mobile/lib/infrastructure/repositories/db.repository.drift.dart
#	mobile/lib/infrastructure/repositories/db.repository.steps.dart
#	mobile/lib/infrastructure/repositories/local_asset.repository.dart
#	mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart
#	mobile/lib/providers/infrastructure/sync.provider.dart
#	mobile/lib/providers/infrastructure/trash_sync.provider.dart
#	mobile/lib/widgets/forms/login/login_form.dart
#	mobile/lib/widgets/settings/advanced_settings.dart
#	mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart
#	mobile/test/domain/services/sync_stream_service_test.dart
#	mobile/test/drift/main/generated/schema.dart
#	mobile/test/infrastructure/repository.mock.dart
2025-11-11 15:43:06 +02:00
Peter Ombodi
9d5d8d449d feat(trash_sync_review): remove code related to restoration review
use auto restoration logic
add alert dialog on move to trash
rework deleteAlreadySynced
update related labels
cleanup TrashSyncService
2025-11-11 14:07:11 +02:00
Peter Ombodi
578cc59989 Merge remote-tracking branch 'public/main' into feature/out-of-sync_assets_v2 2025-11-10 12:55:32 +02:00
Peter Ombodi
b35bf26f02 Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2
# Conflicts:
#	mobile/lib/constants/constants.dart
2025-11-06 18:06:35 +02:00
Peter Ombodi
d0adebff74 refactor(trash_sync_review): refine sync trash flow (draft. v3)
use TabView for separate trashed and restored assets
create restored assets timeline
refactor code
2025-11-06 17:13:26 +02:00
Peter Ombodi
fa4cdadf19 fix(trash_sync_review): modify TrashSync table structure
refine sync trash flow (draft. v2)
2025-10-31 18:23:52 +02:00
Peter Ombodi
cf28e7714f Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2 2025-10-31 12:53:15 +02:00
Peter Ombodi
01c18ed25f fix(trash_sync_review): extend TrashSync table and model by actionType
cleaun-up trash sync table
local sync: trash sync review flow (draft)
advanced settings screen experimental widget
2025-10-30 18:20:18 +02:00
Peter Ombodi
c92951d143 fix(trash_sync): format file 2025-10-29 16:28:56 +02:00
Peter Ombodi
ebe25d5761 fix(trash_sync_review): resolve merge conflicts 2025-10-29 15:35:47 +02:00
Peter Ombodi
5d99aed664 Merge branch 'feature/sync_assets_trashed_state' into feature/out-of-sync_assets_v2
# Conflicts:
#	mobile/lib/domain/services/local_sync.service.dart
#	mobile/lib/domain/services/sync_stream.service.dart
#	mobile/lib/widgets/forms/login/login_form.dart
#	mobile/lib/widgets/settings/advanced_settings.dart
2025-10-29 15:31:42 +02:00
Peter Ombodi
9b03e08b42 Merge remote-tracking branch 'origin/main' into feature/sync_assets_trashed_state 2025-10-29 14:11:12 +02:00
Peter Ombodi
8dda6e5860 refactor(trash_sync): rework MANAGE_MEDIA info widget
show rationale text in permission request alert dialog
refactor setting getter
2025-10-29 14:10:12 +02:00
Peter Ombodi
dc7edbe184 feat(trash_sync_review): rework MANAGE_MEDIA info widget
refactor setting getter
fix tests
2025-10-29 12:24:05 +02:00
Peter Ombodi
5776d9bbe1 feat(trash_sync_review): resolve merge conflicts
review very draft implementation
settings screen draft implementation
2025-10-28 18:35:48 +02:00
Peter Ombodi
9ef253e89b Merge branch 'feature/sync_assets_trashed_state' into feature/out-of-sync_assets_v2
# Conflicts:
#	mobile/lib/domain/services/local_sync.service.dart
#	mobile/lib/domain/services/sync_stream.service.dart
#	mobile/lib/domain/services/timeline.service.dart
#	mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart
#	mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart
#	mobile/lib/widgets/settings/advanced_settings.dart
2025-10-28 12:23:27 +02:00
Peter Ombodi
caff7fb2d4 resolve merge conflicts
add await for alert dialog
add missed request
2025-10-28 11:59:14 +02:00
Peter Ombodi
e65c34998e Merge remote-tracking branch 'origin/main' into feature/sync_assets_trashed_state
# Conflicts:
#	mobile/lib/widgets/forms/login/login_form.dart
#	mobile/test/infrastructure/repository.mock.dart
2025-10-28 09:21:27 +02:00
Peter Ombodi
5fff609a30 Merge branch 'main' into feature/sync_assets_trashed_state 2025-10-25 21:39:28 +03:00
Peter Ombodi
ca88dbf679 refactor(trash_sync): fix format 2025-10-24 18:32:02 +03:00
Peter Ombodi
39d021beec refactor(trash_sync): resolve merge conflicts 2025-10-24 18:24:24 +03:00
Peter Ombodi
8585417400 Merge remote-tracking branch 'origin/main' into feature/sync_assets_trashed_state
# Conflicts:
#	mobile/lib/platform/native_sync_api.g.dart
2025-10-24 18:21:54 +03:00
Peter Ombodi
2dbbc6a22c refactor(trash_sync): add additional checking for experimental trash sync flag and MANAGE_MEDIA permission. 2025-10-24 18:16:08 +03:00
Peter Ombodi
e49bca1986 refactor(trash_sync): integrate MANAGE_MEDIA permission request into login flow and advanced settings 2025-10-23 17:39:59 +03:00
Peter Ombodi
72b9f20291 fix(trash_sync): improve moving to trash 2025-10-22 19:41:12 +03:00
Peter Ombodi
43c499ae16 Merge remote-tracking branch 'origin/main' into feature/sync_assets_trashed_state 2025-10-22 13:25:35 +03:00
Peter Ombodi
db828f50ca fix(trash_sync): refactor code 2025-10-22 13:23:49 +03:00
Peter Ombodi
85ff33289e fix(out-of-sync_trash): resolve merge conflicts (draft) 2025-10-22 12:49:21 +03:00
Peter Ombodi
1549980417 Merge branch 'refs/heads/feature/sync_assets_trashed_state' into feature/out-of-sync_assets_v2
# Conflicts:
#	i18n/en.json
#	mobile/drift_schemas/main/drift_schema_v9.json
#	mobile/lib/domain/models/store.model.dart
#	mobile/lib/domain/services/sync_stream.service.dart
#	mobile/lib/domain/services/trash_sync.service.dart
#	mobile/lib/infrastructure/repositories/db.repository.dart
#	mobile/lib/infrastructure/repositories/db.repository.drift.dart
#	mobile/lib/infrastructure/repositories/db.repository.steps.dart
#	mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart
#	mobile/lib/providers/infrastructure/trash_sync.provider.dart
#	mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart
#	mobile/lib/widgets/common/immich_sliver_app_bar.dart
#	mobile/test/drift/main/generated/schema.dart
#	mobile/test/drift/main/generated/schema_v9.dart
2025-10-22 09:57:35 +03:00
Peter Ombodi
952133a9c8 Merge remote-tracking branch 'origin/main' into feature/sync_assets_trashed_state
# Conflicts:
#	mobile/lib/constants/constants.dart
2025-10-21 11:05:38 +03:00
Peter Ombodi
e16cc2dab5 Merge remote-tracking branch 'origin/main' into feature/sync_assets_trashed_state 2025-10-20 21:46:58 +03:00
Peter Ombodi
f6d267a2c8 fix(trash-sync): reformat file 2025-10-20 18:49:22 +03:00
Peter Ombodi
c49febded0 Merge remote-tracking branch 'origin/feature/sync_assets_trashed_state' into feature/sync_assets_trashed_state 2025-10-20 18:33:18 +03:00
Peter Ombodi
6d97da60e0 Merge branch 'immich-app:main' into feature/sync_assets_trashed_state 2025-10-20 18:32:32 +03:00
Peter Ombodi
16143d36aa fix(trash-sync): refactor code 2025-10-20 18:31:44 +03:00
Peter Ombodi
5ea7218eb8 fix(trash-sync): remove unused code 2025-10-16 12:58:29 +03:00
Peter Ombodi
f296d47d4a fix(trash-sync): remove unused extension 2025-10-16 12:52:21 +03:00
Peter Ombodi
b960efd3e8 fix(trash-sync): fix target table 2025-10-16 11:35:38 +03:00
Peter Ombodi
cf3cdd7e1f Merge remote-tracking branch 'origin/main' into feature/sync_assets_trashed_state
# Conflicts:
#	mobile/ios/Runner/Sync/MessagesImpl.swift
2025-10-16 11:07:33 +03:00
Peter Ombodi
1e2891a519 feat(trash-sync): remove sinceLastCheckpoint param from getTrashedAssets 2025-10-16 10:22:03 +03:00
Peter Ombodi
7bd51d856a refactor(trash-sync): add missed index 2025-10-15 14:41:31 +03:00
Peter Ombodi
d47a2b5669 refactor(trash-sync): optimize performance and fix minor issues 2025-10-15 14:20:48 +03:00
Peter Ombodi
5582a08c3a fix(trash-sync): clean up NativeSyncApiImplBase and correct applyDelta 2025-10-14 18:10:53 +03:00
Peter Ombodi
89d2d04ae4 fix getTrashedAssets params 2025-10-14 15:50:14 +03:00
Peter Ombodi
69b6472adf Merge remote-tracking branch 'origin/main' into feature/sync_assets_trashed_state
# Conflicts:
#	mobile/ios/Runner/Sync/MessagesImpl.swift
2025-10-14 15:36:09 +03:00
Peter Ombodi
6448b3da50 remove albumIds from getTrashedAssets params
fix upsert in trashed local asset repo
refactor code
2025-10-14 15:30:22 +03:00
Peter Ombodi
0c25ee811b Merge remote-tracking branch 'origin/main' into feature/sync_assets_trashed_state 2025-10-13 10:00:18 +03:00
Peter Ombodi
8f6fc47577 fix format 2025-10-09 12:33:27 +03:00
Peter Ombodi
a381e2b42e Merge remote-tracking branch 'origin/main' into feature/sync_assets_trashed_state 2025-10-09 11:53:56 +03:00
Peter Ombodi
c67a147110 optimize sync trashed assets call in full sync mode
refactor code
2025-10-09 11:52:40 +03:00
Peter Ombodi
4d88ffe694 Merge remote-tracking branch 'origin/main' into feature/sync_assets_trashed_state
# Conflicts:
#	mobile/ios/Podfile.lock
2025-10-08 19:07:11 +03:00
Peter Ombodi
887abf5879 update NativeSyncApi on iOS side
remove unused code
2025-10-08 19:05:28 +03:00
Peter Ombodi
519e428b99 rework fetching trashed assets data on native side
optimize handling trashed assets in local sync service
refactor code
2025-10-08 18:47:42 +03:00
Peter Ombodi
cd43564d46 Merge remote-tracking branch 'origin/main' into feature/sync_assets_trashed_state 2025-10-07 18:26:28 +03:00
Peter Ombodi
df0ed1e8da remove trashed asset model
remove trash_sync.service
refactor DriftTrashedLocalAssetRepository, LocalSyncService
2025-10-07 18:24:57 +03:00
Peter Ombodi
ebfab4b01b sync_stream.service depend on repos
refactor assets restoration
update dependencies in tests
2025-10-07 14:18:45 +03:00
Peter Ombodi
ca43c7907e format code 2025-10-06 18:58:02 +03:00
Peter Ombodi
25376e38dd Merge remote-tracking branch 'origin/main' into feature/sync_assets_trashed_state 2025-10-06 18:33:03 +03:00
Peter Ombodi
44ec7744ba reuse exist checksums on trash data update
handle restoration errors
fix import
2025-10-06 18:28:31 +03:00
Peter Ombodi
172102c438 fix generated file 2025-10-06 16:39:54 +03:00
Peter Ombodi
dd2a5b99ba fix migration
fix tests
2025-10-06 16:06:10 +03:00
Peter Ombodi
b2ac41c8bb format code 2025-10-06 11:53:20 +03:00
Peter Ombodi
3eb2bf0342 optimize, refactor code
remove redundant code and checking
getTrashedAssetsForAlbum for iOS
tests for hash trashed assets
2025-10-06 11:41:34 +03:00
Peter Ombodi
3839e72028 fix merge conflicts 2025-10-02 11:32:02 +03:00
Peter Ombodi
3cc3637862 Merge remote-tracking branch 'origin/main' into feature/sync_assets_trashed_state
# Conflicts:
#	mobile/drift_schemas/main/drift_schema_v12.json
#	mobile/lib/infrastructure/repositories/db.repository.dart
#	mobile/lib/infrastructure/repositories/db.repository.steps.dart
#	mobile/test/drift/main/generated/schema.dart
#	mobile/test/drift/main/generated/schema_v12.dart
2025-10-02 10:40:39 +03:00
Peter Ombodi
739f675c19 Merge remote-tracking branch 'origin/main' into feature/sync_assets_trashed_state 2025-09-25 13:14:02 +03:00
Peter Ombodi
4de26b7122 fix label 2025-09-25 13:13:46 +03:00
Peter Ombodi
cdfa7ccbff refactor code
remove unused model
2025-09-25 13:11:14 +03:00
Peter Ombodi
4b2b99942c refactor TrashedAsset model
fix missed data transfering
2025-09-24 18:57:31 +03:00
Peter Ombodi
ccc86d8440 refactor and format code 2025-09-24 18:07:24 +03:00
Peter Ombodi
bec1b30554 trashed_local_asset table mirror of local_asset table structure
trashed_local_asset<->local_asset transfer data on move to trash or restore
refactor code
2025-09-24 16:58:56 +03:00
Peter Ombodi
b15056deb9 fix format 2025-09-19 18:30:41 +03:00
Peter Ombodi
a1fd3ef54a fix merge conflicts 2025-09-19 18:15:27 +03:00
Peter Ombodi
c00526d03a Merge remote-tracking branch 'origin/main' into feature/sync_assets_trashed_state
# Conflicts:
#	mobile/drift_schemas/main/drift_schema_v11.json
#	mobile/lib/infrastructure/repositories/db.repository.dart
#	mobile/lib/infrastructure/repositories/db.repository.steps.dart
#	mobile/test/drift/main/generated/schema_v11.dart
2025-09-19 18:02:16 +03:00
Peter Ombodi
55fe480cc1 Include trashed items in getMediaChanges
Process trashed items delta during incremental sync
2025-09-19 17:55:20 +03:00
Peter Ombodi
5ddb6cd2e1 Merge remote-tracking branch 'origin/main' into feature/sync_assets_trashed_state 2025-09-19 09:16:45 +03:00
Peter Ombodi
9964ad50c2 revert redundant changes 2025-09-19 09:14:08 +03:00
Peter Ombodi
113470c87a use CurrentPlatform instead _platform
fix mocks
2025-09-18 17:58:09 +03:00
Peter Ombodi
42f99e8039 resolve merge conflicts
use updated approach for calculating checksums
2025-09-18 17:27:35 +03:00
Peter Ombodi
bd9e4871ec Merge remote-tracking branch 'origin/main' into feature/sync_assets_trashed_state
# Conflicts:
#	mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt
#	mobile/ios/Runner/Sync/Messages.g.swift
#	mobile/lib/domain/services/hash.service.dart
#	mobile/lib/domain/services/local_sync.service.dart
#	mobile/lib/platform/native_sync_api.g.dart
#	mobile/lib/providers/infrastructure/sync.provider.dart
#	mobile/pigeon/native_sync_api.dart
#	mobile/test/domain/services/hash_service_test.dart
2025-09-18 14:07:13 +03:00
Peter Ombodi
f7e5288173 rework trashed assets handling
- add new table trashed_local_asset
- mirror trashed assets data in trashed_local_asset.
- compute checksums for assets trashed out-of-app.
- restore assets present in trashed_local_asset and non-trashed in remote_asset.
- simplify moving-to-trash logic based on remote_asset events.
2025-09-18 13:55:56 +03:00
Peter Ombodi
3d56a5ca9c Merge remote-tracking branch 'public/main' into feature/sync_assets_trashed_state
# Conflicts:
#	mobile/lib/infrastructure/repositories/local_asset.repository.dart
2025-09-11 12:37:56 +03:00
Peter Ombodi
c1e9e48713 fix index creating on migration 2025-09-10 11:36:39 +03:00
Peter Ombodi
910ec79409 resolve merge conflicts 2025-09-09 19:13:36 +03:00
Peter Ombodi
a2f726e8e7 Merge remote-tracking branch 'public/main' into feature/sync_assets_trashed_state
# Conflicts:
#	mobile/drift_schemas/main/drift_schema_v10.json
#	mobile/lib/infrastructure/repositories/db.repository.dart
#	mobile/lib/infrastructure/repositories/db.repository.drift.dart
#	mobile/lib/infrastructure/repositories/db.repository.steps.dart
#	mobile/test/drift/main/generated/schema_v10.dart
2025-09-09 19:06:33 +03:00
Peter Ombodi
020dfa7818 feat(db): add local_trashed_asset table and integrate with restoration flow
- Add new `local_trashed_asset` table to store metadata of trashed assets
- Save trashed asset info into `local_trashed_asset` before deletion
- Use `local_trashed_asset` as source for asset restoration
- Implement file restoration by `mediaId`
2025-09-09 18:54:37 +03:00
Peter Ombodi
57540f6259 Merge remote-tracking branch 'public/main' into feature/sync_assets_trashed_state
# Conflicts:
#	mobile/test/domain/services/sync_stream_service_test.dart
2025-09-08 17:58:30 +03:00
Peter Ombodi
b8e41494d7 Merge remote-tracking branch 'public/main' into feature/sync_assets_trashed_state 2025-09-08 17:40:01 +03:00
Peter Ombodi
8888657b64 sync trash only for backup-selected assets 2025-09-08 17:38:42 +03:00
Peter Ombodi
468d163e8e fix format 2025-09-08 14:47:56 +03:00
Peter Ombodi
40ac65db46 use checksum for asset restoration
refactro code
2025-09-08 14:29:59 +03:00
Peter Ombodi
b8274c9ed4 Merge remote-tracking branch 'public/main' into feature/sync_assets_trashed_state 2025-09-08 11:02:34 +03:00
Peter Ombodi
c9da959ec2 fix format 2025-09-08 10:57:54 +03:00
Peter Ombodi
f01935376d Merge remote-tracking branch 'public/main' into feature/sync_assets_trashed_state 2025-09-05 21:57:54 +03:00
Peter Ombodi
f7573ae317 Merge remote-tracking branch 'origin/main' into feature/sync_assets_trashed_state 2025-09-05 17:47:17 +03:00
Peter Ombodi
d8fb41e795 group local assets by checksum before moving to trash
delete LocalAssetEntity records when moved to trash
refactor code
2025-09-05 17:41:30 +03:00
Peter Ombodi
c7e4f9db85 process restoreFromTrash sequentially instead of Future.wait 2025-09-03 19:21:15 +03:00
Alex
2ef8d55cc8 Merge branch 'main' into feature/sync_assets_trashed_state 2025-09-02 21:24:46 -05:00
Peter Ombodi
c93b78921f resolve merge conflicts
refactor trash_sync service, action service
add label for Deny/Allow actions buttons
2025-09-02 16:40:59 +03:00
Peter Ombodi
9feb2bea05 Merge branch 'feature/sync_assets_trashed_state' into feature/out-of-sync_assets_v2
# Conflicts:
#	mobile/lib/domain/services/sync_stream.service.dart
#	mobile/lib/domain/services/trash_sync.service.dart
2025-09-02 12:33:02 +03:00
Peter Ombodi
5f2255453b minor changes - remove debugPrint, use hardcoded label (temp) 2025-09-01 23:51:31 +03:00
Peter Ombodi
13f826a5f6 Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2
# Conflicts:
#	i18n/en.json
#	mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart
#	mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart
#	mobile/lib/widgets/common/immich_sliver_app_bar.dart
#	mobile/lib/widgets/settings/advanced_settings.dart
2025-09-01 12:38:57 +03:00
Peter Ombodi
fc19318560 revert changes related to toast
show warning mark on avatar instead
rework out-of-sync button
2025-08-29 16:47:17 +03:00
Peter Ombodi
bec675a420 Merge remote-tracking branch 'origin/main' into feature/sync_assets_trashed_state 2025-08-28 12:44:46 +03:00
Peter Ombodi
b79371b0e0 refactor code (minor nitpicks) 2025-08-28 12:43:38 +03:00
Peter Ombodi
d08d6a3bba resolve merge conflicts 2025-08-27 18:35:01 +03:00
Peter Ombodi
024e234291 Merge remote-tracking branch 'origin/main' into feature/out-of-sync_assets_v2
# Conflicts:
#	i18n/en.json
#	mobile/drift_schemas/main/drift_schema_v7.json
#	mobile/lib/infrastructure/repositories/db.repository.dart
#	mobile/lib/infrastructure/repositories/db.repository.drift.dart
#	mobile/lib/infrastructure/repositories/db.repository.steps.dart
#	mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart
#	mobile/lib/routing/router.dart
#	mobile/test/drift/main/generated/schema.dart
#	mobile/test/drift/main/generated/schema_v7.dart
2025-08-27 17:33:45 +03:00
Peter Ombodi
c0824ba025 refactor code
allow/deny buttons on asset preview (draft)
2025-08-14 17:45:11 +03:00
Peter Ombodi
7381e9355d show toast about exist out-of-sync changes
refactor outOfSyncCountProvider
create appSettingStreamProvider
ImmichToast: add warning mode, add onTap callback
refactor code
2025-08-13 18:25:42 +03:00
Peter Ombodi
dd2caef2fa sync remote restoration
refactor code
2025-08-12 17:33:48 +03:00
Peter Ombodi
01bb756269 refactor(trash-sync): rename setMoveToTrashDecision → resolveRemoteTrash, isApproved → allow 2025-08-12 13:28:12 +03:00
Peter Ombodi
6ca060703e close review page if all conflicts resolved 2025-08-12 12:57:41 +03:00
Peter Ombodi
2c815f3164 refactor(trash-sync): update services and review handling
- Refactor TrashSyncService and ActionService
- Respect isSyncApproved value in timeline.trashSyncReview
- Use i18n values instead of hardcoded strings
- Close review page when out-of-sync record count reaches 0
2025-08-11 18:57:02 +03:00
Peter Ombodi
15b7bc21b0 remove redundant changes 2025-08-11 09:51:00 +03:00
Peter Ombodi
388c4b5717 create new table for out-of-sync records
add new experimental setting
update app setting page
fetch out-of-sync record if new setting
update timeline service for show out-of-sync assets
add out-of-sync review page (draft)
allow-deny actions (draft)
2025-08-08 19:55:52 +03:00
Peter Ombodi
1682766ccb rename TrashService to TrashSyncService to avoid duplicated names
revert changes in original trash.provider.dart
2025-08-08 14:24:36 +03:00
Peter Ombodi
29ec1ddc02 re-format trash.provider.dart 2025-08-07 19:24:00 +03:00
Peter Ombodi
874b2d157e try to re-format trash.provider.dart 2025-08-07 18:59:33 +03:00
Peter Ombodi
22ae3e1da6 parallelize restoreFromTrash calls with Future.wait
format trash.provider.dart
2025-08-07 18:36:59 +03:00
Peter Ombodi
b0aab9a84c refactor code 2025-08-06 18:20:51 +03:00
Peter Ombodi
31bfadb585 refactor code (use separate TrashService) 2025-08-06 11:54:18 +03:00
Peter Ombodi
a89a35beed refactor code
rollback changes in BackgroundServicePlugin
2025-08-05 17:58:02 +03:00
Peter Ombodi
558e1e7654 fix line breaks 2025-07-31 21:26:02 +03:00
Peter Ombodi
bedb5c8741 refactor naming 2025-07-31 21:16:30 +03:00
Peter Ombodi
34a96296f1 fix checking conditions 2025-07-31 21:10:55 +03:00
Peter Ombodi
84ecd6068d fix imports 2025-07-31 21:04:52 +03:00
Peter Ombodi
84f26a0304 feature(mobile, beta, Android): fix rescan 2025-07-31 18:50:54 +03:00
Peter Ombodi
cf920ea438 feature(mobile, beta, Android): handle remote asset trash/restore events and rescan media
- Handle move to trash and restore from trash for remote assets on Android
- Trigger MediaScannerConnection to rescan affected media files
2025-07-31 18:34:36 +03:00
68 changed files with 2407 additions and 402 deletions

View File

@@ -467,10 +467,14 @@
"advanced_settings_proxy_headers_title": "Custom proxy headers [EXPERIMENTAL]",
"advanced_settings_readonly_mode_subtitle": "Enables the read-only mode where the photos can be only viewed, things like selecting multiple images, sharing, casting, delete are all disabled. Enable/Disable read-only via user avatar from the main screen",
"advanced_settings_readonly_mode_title": "Read-only mode",
"advanced_settings_review_remote_deletions_subtitle": "Manually review cloud trash changes. Restorations are applied automatically.",
"advanced_settings_review_remote_deletions_title": "Review remote deletions",
"advanced_settings_self_signed_ssl_subtitle": "Skips SSL certificate verification for the server endpoint. Required for self-signed certificates.",
"advanced_settings_self_signed_ssl_title": "Allow self-signed SSL certificates [EXPERIMENTAL]",
"advanced_settings_sync_remote_deletions_subtitle": "Automatically delete or restore an asset on this device when that action is taken on the web",
"advanced_settings_sync_remote_deletions_title": "Sync remote deletions [EXPERIMENTAL]",
"advanced_settings_sync_remote_deletions_off_subtitle": "Cloud trash changes are ignored",
"advanced_settings_sync_remote_deletions_selector_title": "Sync remote deletions [EXPERIMENTAL]",
"advanced_settings_sync_remote_deletions_subtitle": "Automatically move assets to trash or restore them on this device when that action is taken on the web.",
"advanced_settings_sync_remote_deletions_title": "Auto sync",
"advanced_settings_tile_subtitle": "Advanced user's settings",
"advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting",
"advanced_settings_troubleshooting_title": "Troubleshooting",
@@ -581,6 +585,11 @@
"asset_not_found_on_icloud": "Asset not found on iCloud. the asset may be inaccessible due to bad file stored on iCloud",
"asset_offline": "Asset Offline",
"asset_offline_description": "This external asset is no longer found on disk. Please contact your Immich administrator for help.",
"asset_out_of_sync_title": "Out-of-sync assets list",
"asset_out_of_sync_trash_confirmation_text": "Move {count, plural, one {asset} other {# assets}} to your device trash?",
"asset_out_of_sync_trash_confirmation_title": "Sync trash change",
"asset_out_of_sync_trash_subtitle": "Assets moved to the Immich cloud trash: choose to move them to local trash or keep them on this device.",
"asset_out_of_sync_trash_subtitle_result": "Nothing left to review — all decisions applied.",
"asset_restored_successfully": "Asset restored successfully",
"asset_skipped": "Skipped",
"asset_skipped_in_trash": "In trash",
@@ -599,6 +608,7 @@
"assets_count": "{count, plural, one {# asset} other {# assets}}",
"assets_deleted_permanently": "{count} asset(s) deleted permanently",
"assets_deleted_permanently_from_server": "{count} asset(s) deleted permanently from the Immich server",
"assets_denied_to_moved_to_trash_count": "Keeping local copies of {count, plural, one {# asset} other {# assets}}",
"assets_downloaded_failed": "{count, plural, one {Downloaded # file - {error} file failed} other {Downloaded # files - {error} files failed}}",
"assets_downloaded_successfully": "{count, plural, one {Downloaded # file successfully} other {Downloaded # files successfully}}",
"assets_moved_to_trash_count": "Moved {count, plural, one {# asset} other {# assets}} to trash",
@@ -1359,6 +1369,7 @@
"keep_all": "Keep All",
"keep_description": "Choose what stays on your device when freeing up space.",
"keep_favorites": "Keep favorites",
"keep_in_trash": "Keep in trash",
"keep_on_device": "Keep on device",
"keep_on_device_hint": "Select items to keep on this device",
"keep_this_delete_others": "Keep this, delete others",
@@ -1645,6 +1656,7 @@
"obtainium_configurator": "Obtainium Configurator",
"obtainium_configurator_instructions": "Use Obtainium to install and update the Android app directly from Immich GitHub's release. Create an API key and select a variant to create your Obtainium configuration link",
"ocr": "OCR",
"off": "Off",
"official_immich_resources": "Official Immich Resources",
"offline": "Offline",
"offset": "Offset",
@@ -1914,6 +1926,7 @@
"retry_upload": "Retry upload",
"review_duplicates": "Review duplicates",
"review_large_files": "Review large files",
"review_out_of_sync_changes": "Review out-of-sync changes",
"role": "Role",
"role_editor": "Editor",
"role_viewer": "Viewer",

View File

@@ -48,14 +48,14 @@ FROM python:3.13-slim-trixie@sha256:d168b8d9eb761f4d3fe305ebd04aeb7e7f2de0297cec
RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.28.4/intel-igc-core-2_2.28.4+20760_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.28.4/intel-igc-opencl-2_2.28.4+20760_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/26.05.37020.3/intel-opencl-icd_26.05.37020.3-0_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-core-2_2.32.7+21184_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-opencl-2_2.32.7+21184_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/intel-opencl-icd_26.14.37833.4-0_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb && \
# TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file
wget -nv https://github.com/intel/compute-runtime/releases/download/26.05.37020.3/libigdgmm12_22.9.0_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb && \
dpkg -i *.deb && \
rm *.deb && \
apt-get remove wget -yqq && \

View File

@@ -183,7 +183,10 @@ async def predict(
text: str | None = Form(default=None),
) -> Any:
if image is not None:
inputs: Image | str = await run(lambda: decode_pil(image))
decoded = await run(lambda: decode_pil(image))
if decoded.width == 0 or decoded.height == 0:
raise HTTPException(400, "Image has zero width or height")
inputs: Image | str = decoded
elif text is not None:
inputs = text
else:

View File

@@ -9,12 +9,12 @@ dependencies = [
"aiocache>=0.12.1,<1.0",
"fastapi>=0.95.2,<1.0",
"gunicorn>=21.1.0",
"huggingface-hub>=0.20.1,<1.0",
"huggingface-hub>=1.0,<2.0",
"insightface>=0.7.3,<1.0",
"numpy<2.4.0",
"opencv-python-headless>=4.7.0.72,<5.0",
"orjson>=3.9.5",
"pillow>=12.2,<12.3",
"pillow>=12.2,<13",
"pydantic>=2.0.0,<3",
"pydantic-settings>=2.5.2,<3",
"python-multipart>=0.0.6,<1.0",

View File

@@ -1198,6 +1198,19 @@ class TestLoad:
mock_model.model_format = ModelFormat.ONNX
@pytest.mark.parametrize("size", [(0, 100), (100, 0), (0, 0)])
def test_predict_rejects_empty_image(size: tuple[int, int], deployed_app: TestClient) -> None:
with mock.patch("immich_ml.main.decode_pil", return_value=Image.new("RGB", size)):
response = deployed_app.post(
"http://localhost:3003/predict",
data={"entries": json.dumps({"clip": {"visual": {"modelName": "ViT-B-32__openai"}}})},
files={"image": b"fake image bytes"},
)
assert response.status_code == 400
assert "zero" in response.json()["detail"].lower()
def test_root_endpoint(deployed_app: TestClient) -> None:
response = deployed_app.get("http://localhost:3003")

View File

@@ -751,7 +751,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
@@ -760,7 +760,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.121.0;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile;
PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.profile;
PRODUCT_NAME = "Immich-Profile";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -895,7 +895,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
@@ -904,7 +904,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.121.0;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug;
PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.debug;
PRODUCT_NAME = "Immich-Debug";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -925,7 +925,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
@@ -958,7 +958,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
DEVELOPMENT_TEAM = 2F67MQ8R79;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -975,7 +975,7 @@
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug.Widget;
PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.debug.Widget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
@@ -1001,7 +1001,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
DEVELOPMENT_TEAM = 2F67MQ8R79;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -1041,7 +1041,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
DEVELOPMENT_TEAM = 2F67MQ8R79;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -1057,7 +1057,7 @@
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile.Widget;
PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.profile.Widget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -1081,7 +1081,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -1098,7 +1098,7 @@
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug.ShareExtension;
PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.debug.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
@@ -1125,7 +1125,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -1166,7 +1166,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -1182,7 +1182,7 @@
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile.ShareExtension;
PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.profile.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;

View File

@@ -1,35 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>IntentsSupported</key>
<array>
<string>INSendMessageIntent</string>
</array>
<key>NSExtensionActivationRule</key>
<string>SUBQUERY ( extensionItems, $extensionItem, SUBQUERY ( $extensionItem.attachments,
<dict>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>IntentsSupported</key>
<array>
<string>INSendMessageIntent</string>
</array>
<key>NSExtensionActivationRule</key>
<string>SUBQUERY ( extensionItems, $extensionItem, SUBQUERY ( $extensionItem.attachments,
$attachment, ( ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.file-url"
|| ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image" || ANY
$attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.text" || ANY
$attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.movie" || ANY
$attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" ) ).@count &gt; 0
).@count &gt; 0 </string>
<key>PHSupportedMediaTypes</key>
<array>
<string>Video</string>
<string>Image</string>
</array>
</dict>
<key>NSExtensionMainStoryboard</key>
<string>MainInterface</string>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
</dict>
</dict>
</plist>
<key>PHSupportedMediaTypes</key>
<array>
<string>Video</string>
<string>Image</string>
</array>
</dict>
<key>NSExtensionMainStoryboard</key>
<string>MainInterface</string>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
</dict>
</dict>
</plist>

View File

@@ -1,10 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.immich.share</string>
</array>
</dict>
</plist>
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.immich.share</string>
</array>
</dict>
</plist>

View File

@@ -1,10 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.immich.share</string>
</array>
</dict>
</plist>
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.immich.share</string>
</array>
</dict>
</plist>

View File

@@ -10,6 +10,7 @@ class RemoteAsset extends BaseAsset {
final AssetVisibility visibility;
final String ownerId;
final String? stackId;
final DateTime? deletedAt;
const RemoteAsset({
required this.id,
@@ -20,6 +21,7 @@ class RemoteAsset extends BaseAsset {
required super.type,
required super.createdAt,
required super.updatedAt,
this.deletedAt,
super.width,
super.height,
super.durationMs,
@@ -55,6 +57,7 @@ class RemoteAsset extends BaseAsset {
type: $type,
createdAt: $createdAt,
updatedAt: $updatedAt,
deletedAt: ${deletedAt ?? "<NA>"},
width: ${width ?? "<NA>"},
height: ${height ?? "<NA>"},
durationMs: ${durationMs ?? "<NA>"},
@@ -78,6 +81,7 @@ class RemoteAsset extends BaseAsset {
ownerId == other.ownerId &&
thumbHash == other.thumbHash &&
visibility == other.visibility &&
deletedAt == other.deletedAt &&
stackId == other.stackId;
}
@@ -89,6 +93,7 @@ class RemoteAsset extends BaseAsset {
localId.hashCode ^
thumbHash.hashCode ^
visibility.hashCode ^
deletedAt.hashCode ^
stackId.hashCode;
RemoteAsset copyWith({
@@ -109,6 +114,7 @@ class RemoteAsset extends BaseAsset {
String? livePhotoVideoId,
String? stackId,
bool? isEdited,
DateTime? deletedAt,
}) {
return RemoteAsset(
id: id ?? this.id,
@@ -128,6 +134,7 @@ class RemoteAsset extends BaseAsset {
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
stackId: stackId ?? this.stackId,
isEdited: isEdited ?? this.isEdited,
deletedAt: deletedAt ?? this.deletedAt,
);
}
}
@@ -144,6 +151,7 @@ class RemoteAssetExif extends RemoteAsset {
required super.type,
required super.createdAt,
required super.updatedAt,
super.deletedAt,
super.width,
super.height,
super.durationMs,
@@ -176,6 +184,7 @@ class RemoteAssetExif extends RemoteAsset {
AssetType? type,
DateTime? createdAt,
DateTime? updatedAt,
DateTime? deletedAt,
int? width,
int? height,
int? durationMs,
@@ -205,7 +214,8 @@ class RemoteAssetExif extends RemoteAsset {
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
stackId: stackId ?? this.stackId,
isEdited: isEdited ?? this.isEdited,
exifInfo: exifInfo ?? this.exifInfo, // Use the new parameter
exifInfo: exifInfo ?? this.exifInfo,
deletedAt: deletedAt ?? this.deletedAt,
);
}
}

View File

@@ -0,0 +1,8 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
class RemoteDeletedLocalAsset {
final LocalAsset asset;
final DateTime remoteDeletedAt;
const RemoteDeletedLocalAsset({required this.asset, required this.remoteDeletedAt});
}

View File

@@ -94,7 +94,9 @@ enum StoreKey<T> {
cleanupCutoffDaysAgo<int>._(1011),
cleanupDefaultsInitialized<bool>._(1012),
syncMigrationStatus<String>._(1013);
syncMigrationStatus<String>._(1013),
reviewOutOfSyncChangesAndroid<bool>._(1014),
trashSyncLastCleanup<int>._(1015);
const StoreKey._(this.id);
final int id;

View File

@@ -10,6 +10,7 @@ import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
@@ -23,6 +24,7 @@ class LocalSyncService {
final DriftLocalAssetRepository _localAssetRepository;
final NativeSyncApi _nativeSyncApi;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final DriftTrashSyncRepository _trashSyncRepository;
final LocalFilesManagerRepository _localFilesManager;
final StorageRepository _storageRepository;
final Logger _log = Logger("DeviceSyncService");
@@ -31,12 +33,14 @@ class LocalSyncService {
required DriftLocalAlbumRepository localAlbumRepository,
required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required DriftTrashSyncRepository trashSyncRepository,
required LocalFilesManagerRepository localFilesManager,
required StorageRepository storageRepository,
required NativeSyncApi nativeSyncApi,
}) : _localAlbumRepository = localAlbumRepository,
_localAssetRepository = localAssetRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository,
_trashSyncRepository = trashSyncRepository,
_localFilesManager = localFilesManager,
_storageRepository = storageRepository,
_nativeSyncApi = nativeSyncApi;
@@ -44,7 +48,9 @@ class LocalSyncService {
Future<void> sync({bool full = false}) async {
final Stopwatch stopwatch = Stopwatch()..start();
try {
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
if (CurrentPlatform.isAndroid &&
(Store.get(StoreKey.manageLocalMediaAndroid, false) ||
Store.get(StoreKey.reviewOutOfSyncChangesAndroid, false))) {
final hasPermission = await _localFilesManager.hasManageMediaPermission();
if (hasPermission) {
await _syncTrashedAssets();
@@ -379,22 +385,35 @@ class LocalSyncService {
} else {
_log.info("syncTrashedAssets, No remote assets found for restoration");
}
final localAssetsToTrash = await _trashedLocalAssetRepository.getToTrash();
final reviewMode = Store.get(StoreKey.reviewOutOfSyncChangesAndroid, false);
final localAssetsToTrash = await _localAssetRepository.getToTrash();
if (localAssetsToTrash.isNotEmpty) {
final mediaUrls = await Future.wait(
localAssetsToTrash.values
.expand((e) => e)
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
);
_log.info("Moving to trash ${mediaUrls.join(", ")} assets");
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
if (result) {
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
final flattenedAssetsToTrash = localAssetsToTrash.values.flattened;
if (reviewMode) {
final itemsToReview = flattenedAssetsToTrash.where((la) => la.asset.checksum?.isNotEmpty == true);
_log.fine(
"Apply remote trash action to review for: ${itemsToReview.map((e) => 'id:${e.asset.id}, name:${e.asset.name}').join('|')}",
);
await _trashSyncRepository.upsertReviewCandidates(itemsToReview);
} else {
final mediaUrls = await Future.wait(
flattenedAssetsToTrash.map(
(record) => _storageRepository.getAssetEntityForAsset(record.asset).then((e) => e?.getMediaUrl()),
),
);
_log.info("Moving to trash ${mediaUrls.join(", ")} assets");
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
if (result) {
await _trashedLocalAssetRepository.trashLocalAssets(localAssetsToTrash);
}
}
} else {
_log.info("syncTrashedAssets, No assets found in backup-enabled albums for move to trash");
}
if (reviewMode) {
final result = await _trashSyncRepository.deleteOutdatedThrottled();
_log.info("syncTrashedAssets, outdated deleted: $result");
}
}
}

View File

@@ -3,6 +3,8 @@
import 'dart:async';
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:immich_mobile/domain/models/asset/remote_deleted_local_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
@@ -12,6 +14,7 @@ import 'package:immich_mobile/infrastructure/repositories/storage.repository.dar
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
@@ -32,6 +35,7 @@ class SyncStreamService {
final SyncStreamRepository _syncStreamRepository;
final DriftLocalAssetRepository _localAssetRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final DriftTrashSyncRepository _trashSyncRepository;
final LocalFilesManagerRepository _localFilesManager;
final StorageRepository _storageRepository;
final SyncMigrationRepository _syncMigrationRepository;
@@ -43,6 +47,7 @@ class SyncStreamService {
required SyncStreamRepository syncStreamRepository,
required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required DriftTrashSyncRepository trashSyncRepository,
required LocalFilesManagerRepository localFilesManager,
required StorageRepository storageRepository,
required SyncMigrationRepository syncMigrationRepository,
@@ -52,6 +57,7 @@ class SyncStreamService {
_syncStreamRepository = syncStreamRepository,
_localAssetRepository = localAssetRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository,
_trashSyncRepository = trashSyncRepository,
_localFilesManager = localFilesManager,
_storageRepository = storageRepository,
_syncMigrationRepository = syncMigrationRepository,
@@ -191,17 +197,26 @@ class SyncStreamService {
case SyncEntityType.assetV1:
final remoteSyncAssets = data.cast<SyncAssetV1>();
await _syncStreamRepository.updateAssetsV1(remoteSyncAssets);
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
final hasPermission = await _localFilesManager.hasManageMediaPermission();
if (hasPermission) {
await _handleRemoteTrashed(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.checksum));
await _runWithManageMediaPermission(
logContext: "Trashed Assets",
action: () async {
final reviewMode = Store.get(StoreKey.reviewOutOfSyncChangesAndroid, false);
final trashedAssetsMap = Map<String, DateTime>.fromEntries(
remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => MapEntry(e.checksum, e.deletedAt!)),
);
await _handleRemoteTrashed(trashedAssetsMap, reviewMode);
await _applyRemoteRestoreToLocal();
} else {
_logger.warning("sync Trashed Assets cannot proceed because MANAGE_MEDIA permission is missing");
}
}
},
);
return;
case SyncEntityType.assetDeleteV1:
await _runWithManageMediaPermission(
logContext: "Deleted Assets",
action: () async {
final remoteSyncAssets = data.cast<SyncAssetDeleteV1>();
await _handleRemoteDeleted(remoteSyncAssets.map((e) => e.assetId));
},
);
return _syncStreamRepository.deleteAssetsV1(data.cast());
case SyncEntityType.assetExifV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast());
@@ -382,28 +397,68 @@ class SyncStreamService {
}
}
Future<void> _handleRemoteTrashed(Iterable<String> checksums) async {
if (checksums.isEmpty) {
Future<void> _handleRemoteDeleted(Iterable<String> remoteIds) async {
if (remoteIds.isEmpty) {
return Future.value();
} else {
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(checksums);
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(
Map.fromEntries(remoteIds.map((id) => MapEntry(id, DateTime.now()))),
);
if (localAssetsToTrash.isNotEmpty) {
final mediaUrls = await Future.wait(
localAssetsToTrash.values
.expand((e) => e)
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
);
_logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
if (result) {
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
await _trashLocalAssets(localAssetsToTrash);
} else {
_logger.info("No assets found in backup-enabled albums for remote assets: $remoteIds");
}
}
}
Future<void> _handleRemoteTrashed(Map<String, DateTime> trashedAssetsMap, bool reviewMode) async {
if (trashedAssetsMap.isEmpty) {
return Future.value();
} else {
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(trashedAssetsMap);
if (localAssetsToTrash.isNotEmpty) {
final flattenedAssetsToTrash = localAssetsToTrash.values.flattened;
if (reviewMode) {
final itemsToReview = flattenedAssetsToTrash.where((la) => la.asset.checksum?.isNotEmpty == true);
_logger.info(
"Apply remote trash action to review for: ${itemsToReview.map((e) => 'id:${e.asset.id}, name:${e.asset.name}').join('*')}",
);
await _trashSyncRepository.upsertReviewCandidates(itemsToReview);
} else {
final mediaUrls = await Future.wait(
flattenedAssetsToTrash.map(
(assetRecord) =>
_storageRepository.getAssetEntityForAsset(assetRecord.asset).then((e) => e?.getMediaUrl()),
),
);
_logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
if (result) {
await _trashedLocalAssetRepository.trashLocalAssets(localAssetsToTrash);
}
}
} else {
_logger.info("No assets found in backup-enabled albums for assets: $checksums");
_logger.info("No assets found in backup-enabled albums for assets: $trashedAssetsMap");
}
}
}
Future<void> _trashLocalAssets(Map<String, List<RemoteDeletedLocalAsset>> localAssetsToTrash) async {
final mediaUrls = await Future.wait(
localAssetsToTrash.values
.expand((e) => e)
.map(
(localAsset) => _storageRepository.getAssetEntityForAsset(localAsset.asset).then((e) => e?.getMediaUrl()),
),
);
_logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
if (result) {
await _trashedLocalAssetRepository.trashLocalAssets(localAssetsToTrash);
}
}
Future<void> _applyRemoteRestoreToLocal() async {
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
if (assetsToRestore.isNotEmpty) {
@@ -413,4 +468,23 @@ class SyncStreamService {
_logger.info("No remote assets found for restoration");
}
}
Future<void> _runWithManageMediaPermission({
required String logContext,
required Future<void> Function() action,
}) async {
if (!CurrentPlatform.isAndroid ||
(!Store.get(StoreKey.manageLocalMediaAndroid, false) &&
!Store.get(StoreKey.reviewOutOfSyncChangesAndroid, false))) {
return;
}
final hasPermission = await _localFilesManager.hasManageMediaPermission();
if (!hasPermission) {
_logger.warning("sync $logContext cannot proceed because MANAGE_MEDIA permission is missing");
return;
}
await action();
}
}

View File

@@ -35,6 +35,7 @@ enum TimelineOrigin {
deepLink,
albumActivities,
folder,
syncTrash,
}
class TimelineFactory {
@@ -65,6 +66,8 @@ class TimelineFactory {
TimelineService trash(String userId) => TimelineService(_timelineRepository.trash(userId, groupBy));
TimelineService toTrashSyncReview() => TimelineService(_timelineRepository.toTrashSyncReview(groupBy));
TimelineService archive(String userId) => TimelineService(_timelineRepository.archived(userId, groupBy));
TimelineService lockedFolder(String userId) => TimelineService(_timelineRepository.locked(userId, groupBy));

View File

@@ -0,0 +1,13 @@
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
class TrashSyncService {
final DriftTrashSyncRepository _trashSyncRepository;
const TrashSyncService({required DriftTrashSyncRepository trashSyncRepository})
: _trashSyncRepository = trashSyncRepository;
Stream<int> watchPendingApprovalAssetCount() => _trashSyncRepository.watchPendingApprovalAssetCount();
Stream<bool> watchIsAssetApprovalPending(String checksum) =>
_trashSyncRepository.watchIsAssetApprovalPending(checksum);
}

View File

@@ -4,7 +4,7 @@ import 'local_asset.entity.dart';
import 'local_album.entity.dart';
import 'local_album_asset.entity.dart';
mergedAsset:
mergedAsset:
SELECT
rae.id as remote_id,
(SELECT lae.id FROM local_asset_entity lae WHERE lae.checksum = rae.checksum LIMIT 1) as local_id,

View File

@@ -0,0 +1,19 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_trash_sync_is_sync_approved ON trash_sync_entity (is_sync_approved)')
@TableIndex.sql(
'CREATE INDEX IF NOT EXISTS idx_trash_sync_checksum_status ON trash_sync_entity (checksum, is_sync_approved)',
)
class TrashSyncEntity extends Table with DriftDefaultsMixin {
const TrashSyncEntity();
TextColumn get checksum => text()();
BoolColumn get isSyncApproved => boolean().nullable()();
DateTimeColumn get remoteDeletedAt => dateTime()();
@override
Set<Column> get primaryKey => {checksum};
}

View File

@@ -0,0 +1,461 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart'
as i1;
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.dart'
as i2;
typedef $$TrashSyncEntityTableCreateCompanionBuilder =
i1.TrashSyncEntityCompanion Function({
required String checksum,
i0.Value<bool?> isSyncApproved,
required DateTime remoteDeletedAt,
});
typedef $$TrashSyncEntityTableUpdateCompanionBuilder =
i1.TrashSyncEntityCompanion Function({
i0.Value<String> checksum,
i0.Value<bool?> isSyncApproved,
i0.Value<DateTime> remoteDeletedAt,
});
class $$TrashSyncEntityTableFilterComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$TrashSyncEntityTable> {
$$TrashSyncEntityTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnFilters<String> get checksum => $composableBuilder(
column: $table.checksum,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<bool> get isSyncApproved => $composableBuilder(
column: $table.isSyncApproved,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<DateTime> get remoteDeletedAt => $composableBuilder(
column: $table.remoteDeletedAt,
builder: (column) => i0.ColumnFilters(column),
);
}
class $$TrashSyncEntityTableOrderingComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$TrashSyncEntityTable> {
$$TrashSyncEntityTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnOrderings<String> get checksum => $composableBuilder(
column: $table.checksum,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<bool> get isSyncApproved => $composableBuilder(
column: $table.isSyncApproved,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<DateTime> get remoteDeletedAt => $composableBuilder(
column: $table.remoteDeletedAt,
builder: (column) => i0.ColumnOrderings(column),
);
}
class $$TrashSyncEntityTableAnnotationComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$TrashSyncEntityTable> {
$$TrashSyncEntityTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.GeneratedColumn<String> get checksum =>
$composableBuilder(column: $table.checksum, builder: (column) => column);
i0.GeneratedColumn<bool> get isSyncApproved => $composableBuilder(
column: $table.isSyncApproved,
builder: (column) => column,
);
i0.GeneratedColumn<DateTime> get remoteDeletedAt => $composableBuilder(
column: $table.remoteDeletedAt,
builder: (column) => column,
);
}
class $$TrashSyncEntityTableTableManager
extends
i0.RootTableManager<
i0.GeneratedDatabase,
i1.$TrashSyncEntityTable,
i1.TrashSyncEntityData,
i1.$$TrashSyncEntityTableFilterComposer,
i1.$$TrashSyncEntityTableOrderingComposer,
i1.$$TrashSyncEntityTableAnnotationComposer,
$$TrashSyncEntityTableCreateCompanionBuilder,
$$TrashSyncEntityTableUpdateCompanionBuilder,
(
i1.TrashSyncEntityData,
i0.BaseReferences<
i0.GeneratedDatabase,
i1.$TrashSyncEntityTable,
i1.TrashSyncEntityData
>,
),
i1.TrashSyncEntityData,
i0.PrefetchHooks Function()
> {
$$TrashSyncEntityTableTableManager(
i0.GeneratedDatabase db,
i1.$TrashSyncEntityTable table,
) : super(
i0.TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
i1.$$TrashSyncEntityTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
i1.$$TrashSyncEntityTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () => i1
.$$TrashSyncEntityTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback:
({
i0.Value<String> checksum = const i0.Value.absent(),
i0.Value<bool?> isSyncApproved = const i0.Value.absent(),
i0.Value<DateTime> remoteDeletedAt = const i0.Value.absent(),
}) => i1.TrashSyncEntityCompanion(
checksum: checksum,
isSyncApproved: isSyncApproved,
remoteDeletedAt: remoteDeletedAt,
),
createCompanionCallback:
({
required String checksum,
i0.Value<bool?> isSyncApproved = const i0.Value.absent(),
required DateTime remoteDeletedAt,
}) => i1.TrashSyncEntityCompanion.insert(
checksum: checksum,
isSyncApproved: isSyncApproved,
remoteDeletedAt: remoteDeletedAt,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
),
);
}
typedef $$TrashSyncEntityTableProcessedTableManager =
i0.ProcessedTableManager<
i0.GeneratedDatabase,
i1.$TrashSyncEntityTable,
i1.TrashSyncEntityData,
i1.$$TrashSyncEntityTableFilterComposer,
i1.$$TrashSyncEntityTableOrderingComposer,
i1.$$TrashSyncEntityTableAnnotationComposer,
$$TrashSyncEntityTableCreateCompanionBuilder,
$$TrashSyncEntityTableUpdateCompanionBuilder,
(
i1.TrashSyncEntityData,
i0.BaseReferences<
i0.GeneratedDatabase,
i1.$TrashSyncEntityTable,
i1.TrashSyncEntityData
>,
),
i1.TrashSyncEntityData,
i0.PrefetchHooks Function()
>;
i0.Index get idxTrashSyncIsSyncApproved => i0.Index(
'idx_trash_sync_is_sync_approved',
'CREATE INDEX IF NOT EXISTS idx_trash_sync_is_sync_approved ON trash_sync_entity (is_sync_approved)',
);
class $TrashSyncEntityTable extends i2.TrashSyncEntity
with i0.TableInfo<$TrashSyncEntityTable, i1.TrashSyncEntityData> {
@override
final i0.GeneratedDatabase attachedDatabase;
final String? _alias;
$TrashSyncEntityTable(this.attachedDatabase, [this._alias]);
static const i0.VerificationMeta _checksumMeta = const i0.VerificationMeta(
'checksum',
);
@override
late final i0.GeneratedColumn<String> checksum = i0.GeneratedColumn<String>(
'checksum',
aliasedName,
false,
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
);
static const i0.VerificationMeta _isSyncApprovedMeta =
const i0.VerificationMeta('isSyncApproved');
@override
late final i0.GeneratedColumn<bool> isSyncApproved = i0.GeneratedColumn<bool>(
'is_sync_approved',
aliasedName,
true,
type: i0.DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'CHECK ("is_sync_approved" IN (0, 1))',
),
);
static const i0.VerificationMeta _remoteDeletedAtMeta =
const i0.VerificationMeta('remoteDeletedAt');
@override
late final i0.GeneratedColumn<DateTime> remoteDeletedAt =
i0.GeneratedColumn<DateTime>(
'remote_deleted_at',
aliasedName,
false,
type: i0.DriftSqlType.dateTime,
requiredDuringInsert: true,
);
@override
List<i0.GeneratedColumn> get $columns => [
checksum,
isSyncApproved,
remoteDeletedAt,
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'trash_sync_entity';
@override
i0.VerificationContext validateIntegrity(
i0.Insertable<i1.TrashSyncEntityData> instance, {
bool isInserting = false,
}) {
final context = i0.VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('checksum')) {
context.handle(
_checksumMeta,
checksum.isAcceptableOrUnknown(data['checksum']!, _checksumMeta),
);
} else if (isInserting) {
context.missing(_checksumMeta);
}
if (data.containsKey('is_sync_approved')) {
context.handle(
_isSyncApprovedMeta,
isSyncApproved.isAcceptableOrUnknown(
data['is_sync_approved']!,
_isSyncApprovedMeta,
),
);
}
if (data.containsKey('remote_deleted_at')) {
context.handle(
_remoteDeletedAtMeta,
remoteDeletedAt.isAcceptableOrUnknown(
data['remote_deleted_at']!,
_remoteDeletedAtMeta,
),
);
} else if (isInserting) {
context.missing(_remoteDeletedAtMeta);
}
return context;
}
@override
Set<i0.GeneratedColumn> get $primaryKey => {checksum};
@override
i1.TrashSyncEntityData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return i1.TrashSyncEntityData(
checksum: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}checksum'],
)!,
isSyncApproved: attachedDatabase.typeMapping.read(
i0.DriftSqlType.bool,
data['${effectivePrefix}is_sync_approved'],
),
remoteDeletedAt: attachedDatabase.typeMapping.read(
i0.DriftSqlType.dateTime,
data['${effectivePrefix}remote_deleted_at'],
)!,
);
}
@override
$TrashSyncEntityTable createAlias(String alias) {
return $TrashSyncEntityTable(attachedDatabase, alias);
}
@override
bool get withoutRowId => true;
@override
bool get isStrict => true;
}
class TrashSyncEntityData extends i0.DataClass
implements i0.Insertable<i1.TrashSyncEntityData> {
final String checksum;
final bool? isSyncApproved;
final DateTime remoteDeletedAt;
const TrashSyncEntityData({
required this.checksum,
this.isSyncApproved,
required this.remoteDeletedAt,
});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
map['checksum'] = i0.Variable<String>(checksum);
if (!nullToAbsent || isSyncApproved != null) {
map['is_sync_approved'] = i0.Variable<bool>(isSyncApproved);
}
map['remote_deleted_at'] = i0.Variable<DateTime>(remoteDeletedAt);
return map;
}
factory TrashSyncEntityData.fromJson(
Map<String, dynamic> json, {
i0.ValueSerializer? serializer,
}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return TrashSyncEntityData(
checksum: serializer.fromJson<String>(json['checksum']),
isSyncApproved: serializer.fromJson<bool?>(json['isSyncApproved']),
remoteDeletedAt: serializer.fromJson<DateTime>(json['remoteDeletedAt']),
);
}
@override
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'checksum': serializer.toJson<String>(checksum),
'isSyncApproved': serializer.toJson<bool?>(isSyncApproved),
'remoteDeletedAt': serializer.toJson<DateTime>(remoteDeletedAt),
};
}
i1.TrashSyncEntityData copyWith({
String? checksum,
i0.Value<bool?> isSyncApproved = const i0.Value.absent(),
DateTime? remoteDeletedAt,
}) => i1.TrashSyncEntityData(
checksum: checksum ?? this.checksum,
isSyncApproved: isSyncApproved.present
? isSyncApproved.value
: this.isSyncApproved,
remoteDeletedAt: remoteDeletedAt ?? this.remoteDeletedAt,
);
TrashSyncEntityData copyWithCompanion(i1.TrashSyncEntityCompanion data) {
return TrashSyncEntityData(
checksum: data.checksum.present ? data.checksum.value : this.checksum,
isSyncApproved: data.isSyncApproved.present
? data.isSyncApproved.value
: this.isSyncApproved,
remoteDeletedAt: data.remoteDeletedAt.present
? data.remoteDeletedAt.value
: this.remoteDeletedAt,
);
}
@override
String toString() {
return (StringBuffer('TrashSyncEntityData(')
..write('checksum: $checksum, ')
..write('isSyncApproved: $isSyncApproved, ')
..write('remoteDeletedAt: $remoteDeletedAt')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(checksum, isSyncApproved, remoteDeletedAt);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is i1.TrashSyncEntityData &&
other.checksum == this.checksum &&
other.isSyncApproved == this.isSyncApproved &&
other.remoteDeletedAt == this.remoteDeletedAt);
}
class TrashSyncEntityCompanion
extends i0.UpdateCompanion<i1.TrashSyncEntityData> {
final i0.Value<String> checksum;
final i0.Value<bool?> isSyncApproved;
final i0.Value<DateTime> remoteDeletedAt;
const TrashSyncEntityCompanion({
this.checksum = const i0.Value.absent(),
this.isSyncApproved = const i0.Value.absent(),
this.remoteDeletedAt = const i0.Value.absent(),
});
TrashSyncEntityCompanion.insert({
required String checksum,
this.isSyncApproved = const i0.Value.absent(),
required DateTime remoteDeletedAt,
}) : checksum = i0.Value(checksum),
remoteDeletedAt = i0.Value(remoteDeletedAt);
static i0.Insertable<i1.TrashSyncEntityData> custom({
i0.Expression<String>? checksum,
i0.Expression<bool>? isSyncApproved,
i0.Expression<DateTime>? remoteDeletedAt,
}) {
return i0.RawValuesInsertable({
if (checksum != null) 'checksum': checksum,
if (isSyncApproved != null) 'is_sync_approved': isSyncApproved,
if (remoteDeletedAt != null) 'remote_deleted_at': remoteDeletedAt,
});
}
i1.TrashSyncEntityCompanion copyWith({
i0.Value<String>? checksum,
i0.Value<bool?>? isSyncApproved,
i0.Value<DateTime>? remoteDeletedAt,
}) {
return i1.TrashSyncEntityCompanion(
checksum: checksum ?? this.checksum,
isSyncApproved: isSyncApproved ?? this.isSyncApproved,
remoteDeletedAt: remoteDeletedAt ?? this.remoteDeletedAt,
);
}
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
if (checksum.present) {
map['checksum'] = i0.Variable<String>(checksum.value);
}
if (isSyncApproved.present) {
map['is_sync_approved'] = i0.Variable<bool>(isSyncApproved.value);
}
if (remoteDeletedAt.present) {
map['remote_deleted_at'] = i0.Variable<DateTime>(remoteDeletedAt.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('TrashSyncEntityCompanion(')
..write('checksum: $checksum, ')
..write('isSyncApproved: $isSyncApproved, ')
..write('remoteDeletedAt: $remoteDeletedAt')
..write(')'))
.toString();
}
}
i0.Index get idxTrashSyncChecksumStatus => i0.Index(
'idx_trash_sync_checksum_status',
'CREATE INDEX IF NOT EXISTS idx_trash_sync_checksum_status ON trash_sync_entity (checksum, is_sync_approved)',
);

View File

@@ -23,6 +23,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
@@ -53,6 +54,7 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.da
StoreEntity,
TrashedLocalAssetEntity,
AssetEditEntity,
TrashSyncEntity,
],
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
)
@@ -249,6 +251,9 @@ class Drift extends $Drift {
from23To24: (m, v24) async {
await customStatement('DROP INDEX IF EXISTS idx_remote_album_owner_id');
await m.alterTable(TableMigration(v24.remoteAlbumEntity));
// await m.create(v23.trashSyncEntity);
// await m.createIndex(v23.idxTrashSyncIsSyncApproved);
// await m.createIndex(v23.idxTrashSyncChecksumStatus);
},
),
);

View File

@@ -43,9 +43,11 @@ import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity
as i20;
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart'
as i21;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart'
as i22;
import 'package:drift/internal/modular.dart' as i23;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
as i23;
import 'package:drift/internal/modular.dart' as i24;
abstract class $Drift extends i0.GeneratedDatabase {
$Drift(i0.QueryExecutor e) : super(e);
@@ -89,9 +91,11 @@ abstract class $Drift extends i0.GeneratedDatabase {
.$TrashedLocalAssetEntityTable(this);
late final i21.$AssetEditEntityTable assetEditEntity = i21
.$AssetEditEntityTable(this);
i22.MergedAssetDrift get mergedAssetDrift => i23.ReadDatabaseContainer(
late final i22.$TrashSyncEntityTable trashSyncEntity = i22
.$TrashSyncEntityTable(this);
i23.MergedAssetDrift get mergedAssetDrift => i24.ReadDatabaseContainer(
this,
).accessor<i22.MergedAssetDrift>(i22.MergedAssetDrift.new);
).accessor<i23.MergedAssetDrift>(i23.MergedAssetDrift.new);
@override
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
@@ -129,6 +133,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
storeEntity,
trashedLocalAssetEntity,
assetEditEntity,
trashSyncEntity,
i10.idxPartnerSharedWithId,
i11.idxLatLng,
i12.idxRemoteAlbumAssetAlbumAsset,
@@ -139,6 +144,8 @@ abstract class $Drift extends i0.GeneratedDatabase {
i20.idxTrashedLocalAssetChecksum,
i20.idxTrashedLocalAssetAlbum,
i21.idxAssetEditAssetId,
i22.idxTrashSyncIsSyncApproved,
i22.idxTrashSyncChecksumStatus,
];
@override
i0.StreamQueryUpdateRules
@@ -389,4 +396,6 @@ class $DriftManager {
);
i21.$$AssetEditEntityTableTableManager get assetEditEntity =>
i21.$$AssetEditEntityTableTableManager(_db, _db.assetEditEntity);
i22.$$TrashSyncEntityTableTableManager get trashSyncEntity =>
i22.$$TrashSyncEntityTableTableManager(_db, _db.trashSyncEntity);
}

View File

@@ -6,6 +6,7 @@ import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset/remote_deleted_local_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
@@ -109,31 +110,44 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
return query.map((localAlbum) => localAlbum.toDto()).get();
}
Future<Map<String, List<LocalAsset>>> getAssetsFromBackupAlbums(Iterable<String> checksums) async {
if (checksums.isEmpty) {
Future<Map<String, List<RemoteDeletedLocalAsset>>> getAssetsFromBackupAlbums(
Map<String, DateTime> trashedAssetsMap,
) async {
if (trashedAssetsMap.isEmpty) {
return {};
}
final result = <String, List<LocalAsset>>{};
final result = <String, List<RemoteDeletedLocalAsset>>{};
for (final slice in checksums.toSet().slices(kDriftMaxChunk)) {
for (final slice in trashedAssetsMap.keys.toSet().slices(kDriftMaxChunk)) {
final rows =
await (_db.select(_db.localAlbumAssetEntity).join([
innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id)),
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
innerJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum),
useColumns: false,
),
])..where(
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
_db.localAssetEntity.checksum.isIn(slice),
_db.remoteAssetEntity.id.isIn(slice),
))
.get();
for (final row in rows) {
final albumId = row.readTable(_db.localAlbumAssetEntity).albumId;
final assetData = row.readTable(_db.localAssetEntity);
final asset = assetData.toDto();
(result[albumId] ??= <LocalAsset>[]).add(asset);
(result[albumId] ??= <RemoteDeletedLocalAsset>[]).add(
RemoteDeletedLocalAsset(asset: assetData.toDto(), remoteDeletedAt: trashedAssetsMap[assetData.checksum]!),
);
}
}
return result;
}
@@ -224,4 +238,74 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
updateKind: UpdateKind.update,
);
}
Future<Map<String, List<RemoteDeletedLocalAsset>>> getToTrash() async {
final result = <String, List<RemoteDeletedLocalAsset>>{};
final rows =
await (_db.select(_db.localAlbumAssetEntity).join([
innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id)),
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
leftOuterJoin(
_db.remoteAssetEntity,
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum),
),
])..where(
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
_db.remoteAssetEntity.deletedAt.isNotNull(),
))
.get();
for (final row in rows) {
final albumId = row.readTable(_db.localAlbumAssetEntity).albumId;
final remoteDeletedAt = row.read(_db.remoteAssetEntity.deletedAt);
final asset = row.readTable(_db.localAssetEntity).toDto();
(result[albumId] ??= <RemoteDeletedLocalAsset>[]).add(
RemoteDeletedLocalAsset(asset: asset, remoteDeletedAt: remoteDeletedAt!),
);
}
return result;
}
Future<List<RemoteDeletedLocalAsset>> getRemoteTrashedLocalAssets(Iterable<String> checksums) {
if (checksums.isEmpty) {
return Future.value([]);
}
final selectionQuery =
_db.localAlbumAssetEntity.selectOnly().join([
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
])
..addColumns([_db.localAlbumAssetEntity.assetId])
..where(
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id) &
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected),
);
final query = _db.localAssetEntity.select().addColumns([_db.remoteAssetEntity.deletedAt]).join([
innerJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum),
useColumns: false,
),
]);
final whereClause =
_db.localAssetEntity.checksum.isIn(checksums) &
existsQuery(selectionQuery) &
_db.remoteAssetEntity.deletedAt.isNotNull();
query.where(whereClause);
return query.map((row) {
final asset = row.readTable(_db.localAssetEntity).toDto();
final remoteDeletedAt = row.read(_db.remoteAssetEntity.deletedAt)!;
return RemoteDeletedLocalAsset(asset: asset, remoteDeletedAt: remoteDeletedAt);
}).get();
}
}

View File

@@ -332,6 +332,12 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
joinLocal: true,
);
TimelineQuery toTrashSyncReview(GroupAssetsBy groupBy) => (
bucketSource: () => _watchTrashSyncBucket(groupBy: groupBy),
assetSource: (offset, count) => _getToTrashSyncBucketAssets(offset: offset, count: count),
origin: TimelineOrigin.syncTrash,
);
TimelineQuery archived(String userId, GroupAssetsBy groupBy) => _remoteQueryBuilder(
filter: (row) =>
row.deletedAt.isNull() & row.ownerId.equals(userId) & row.visibility.equalsValue(AssetVisibility.archive),
@@ -672,6 +678,56 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
return query.map((row) => row.toDto()).get();
}
}
Stream<List<Bucket>> _watchTrashSyncBucket({GroupAssetsBy groupBy = GroupAssetsBy.day}) {
if (groupBy == GroupAssetsBy.none) {
// TODO: implement GroupAssetBy for place
throw UnsupportedError("GroupAssetsBy.none is not supported for watchPlaceBucket");
}
final assetCountExp = _db.remoteAssetEntity.id.count();
final dateExp = _db.remoteAssetEntity.createdAt.dateFmt(groupBy);
final pendingTrashChecksums = _db.trashSyncEntity.selectOnly()
..addColumns([_db.trashSyncEntity.checksum])
..where(_db.trashSyncEntity.isSyncApproved.isNull())
..groupBy([_db.trashSyncEntity.checksum]);
final query = _db.remoteAssetEntity.selectOnly()
..addColumns([assetCountExp, dateExp])
..where(
_db.remoteAssetEntity.deletedAt.isNotNull() &
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
_db.remoteAssetEntity.checksum.isInQuery(pendingTrashChecksums),
)
..groupBy([dateExp])
..orderBy([OrderingTerm.desc(dateExp)]);
return query.map((row) {
final timeline = row.read(dateExp)!.truncateDate(groupBy);
final assetCount = row.read(assetCountExp)!;
return TimeBucket(date: timeline, assetCount: assetCount);
}).watch();
}
Future<List<BaseAsset>> _getToTrashSyncBucketAssets({required int offset, required int count}) {
final pendingTrashChecksums = _db.trashSyncEntity.selectOnly()
..addColumns([_db.trashSyncEntity.checksum])
..where(_db.trashSyncEntity.isSyncApproved.isNull())
..groupBy([_db.trashSyncEntity.checksum]);
final query = _db.remoteAssetEntity.select()
..where(
(tbl) =>
tbl.deletedAt.isNotNull() &
tbl.visibility.equalsValue(AssetVisibility.timeline) &
tbl.checksum.isInQuery(pendingTrashChecksums),
)
..orderBy([(tbl) => OrderingTerm.desc(tbl.createdAt)])
..limit(count, offset: offset);
return query.map((row) => row.toDto()).get();
}
}
List<Bucket> _generateBuckets(int count) {

View File

@@ -0,0 +1,137 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/asset/remote_deleted_local_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class DriftTrashSyncRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftTrashSyncRepository(this._db) : super(_db);
Future<void> upsertReviewCandidates(Iterable<RemoteDeletedLocalAsset> itemsToReview) async {
if (itemsToReview.isEmpty) {
return Future.value();
}
final existingEntities = <TrashSyncEntityData>[];
final checksums = itemsToReview.map((e) => e.asset.checksum).nonNulls;
for (final slice in checksums.slices(kDriftMaxChunk)) {
final sliceResult = await (_db.trashSyncEntity.select()..where((tbl) => tbl.checksum.isIn(slice))).get();
existingEntities.addAll(sliceResult);
}
final existingMap = {for (var e in existingEntities) e.checksum: e};
return _db.batch((batch) {
for (var item in itemsToReview) {
final existing = existingMap[item.asset.checksum];
if (existing == null ||
(existing.isSyncApproved == false && item.remoteDeletedAt.isAfter(existing.remoteDeletedAt))) {
batch.insert(
_db.trashSyncEntity,
TrashSyncEntityCompanion.insert(checksum: item.asset.checksum!, remoteDeletedAt: item.remoteDeletedAt),
onConflict: DoUpdate(
(_) => TrashSyncEntityCompanion.custom(
remoteDeletedAt: Variable(item.remoteDeletedAt),
isSyncApproved: const Variable(null),
),
),
);
}
}
});
}
Future<void> updateApproves(Iterable<String> checksums, bool isSyncApproved) {
if (checksums.isEmpty) {
return Future.value();
}
return _db.batch((batch) {
batch.update(
_db.trashSyncEntity,
TrashSyncEntityCompanion(isSyncApproved: Value(isSyncApproved)),
where: (tbl) => tbl.checksum.isIn(checksums),
);
});
}
Future<int> deleteOutdated() async {
final remoteAliveSelect = _db.selectOnly(_db.remoteAssetEntity)
..addColumns([_db.remoteAssetEntity.checksum])
..where(_db.remoteAssetEntity.deletedAt.isNull());
final localTrashedSelect = _db.selectOnly(_db.trashedLocalAssetEntity)
..addColumns([_db.trashedLocalAssetEntity.checksum]);
final query = _db.delete(_db.trashSyncEntity)
..where((row) => row.isSyncApproved.isNull() | row.isSyncApproved.equals(false))
..where((row) => row.checksum.isInQuery(remoteAliveSelect) | row.checksum.isInQuery(localTrashedSelect));
final deletedMatched = await query.go();
final localTrashedChecksums = _db.selectOnly(_db.trashedLocalAssetEntity)
..addColumns([_db.trashedLocalAssetEntity.checksum])
..where(_db.trashedLocalAssetEntity.checksum.isNotNull());
final localAssetChecksums = _db.selectOnly(_db.localAssetEntity)
..addColumns([_db.localAssetEntity.checksum])
..where(_db.localAssetEntity.checksum.isNotNull());
final orphanQuery = _db.delete(_db.trashSyncEntity)
..where(
(row) =>
(row.isSyncApproved.equals(false) & row.checksum.isNotInQuery(localAssetChecksums)) |
(row.isSyncApproved.equals(true) & row.checksum.isNotInQuery(localTrashedChecksums)),
);
final deletedOrphans = await orphanQuery.go();
return deletedMatched + deletedOrphans;
}
Future<int> deleteOutdatedThrottled({Duration minInterval = const Duration(hours: 8)}) async {
final lastRunMillis = await _getLastCleanupTimeMillis();
final nowMillis = DateTime.now().millisecondsSinceEpoch;
if (lastRunMillis != null && nowMillis - lastRunMillis < minInterval.inMilliseconds) {
return 0;
}
final result = await deleteOutdated();
await _setLastCleanupTimeMillis(nowMillis);
return result;
}
Stream<int> watchPendingApprovalAssetCount() {
final countExpr = _db.trashSyncEntity.checksum.count(distinct: true);
final q = _db.selectOnly(_db.trashSyncEntity)
..addColumns([countExpr])
..where(_db.trashSyncEntity.isSyncApproved.isNull());
return q.watchSingle().map((row) => row.read(countExpr) ?? 0).distinct();
}
Stream<bool> watchIsAssetApprovalPending(String checksum) {
final query = _db.selectOnly(_db.trashSyncEntity)
..addColumns([_db.trashSyncEntity.checksum])
..where((_db.trashSyncEntity.checksum.equals(checksum) & _db.trashSyncEntity.isSyncApproved.isNull()))
..limit(1);
return query.watchSingleOrNull().map((row) => row != null).distinct();
}
Future<int?> _getLastCleanupTimeMillis() async {
final entity = await _db.managers.storeEntity
.filter((entity) => entity.id.equals(StoreKey.trashSyncLastCleanup.id))
.getSingleOrNull();
return entity?.intValue;
}
Future<void> _setLastCleanupTimeMillis(int millis) async {
await _db.storeEntity.insertOnConflictUpdate(
StoreEntityCompanion(id: Value(StoreKey.trashSyncLastCleanup.id), intValue: Value(millis)),
);
}
}

View File

@@ -3,7 +3,7 @@ import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/domain/models/asset/remote_deleted_local_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
@@ -125,7 +125,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
.map((row) => row.read<int>(_db.trashedLocalAssetEntity.id.count()) ?? 0);
}
Future<void> trashLocalAsset(Map<String, List<LocalAsset>> assetsByAlbums) async {
Future<void> trashLocalAssets(Map<String, List<RemoteDeletedLocalAsset>> assetsByAlbums) async {
if (assetsByAlbums.isEmpty) {
return Future.value();
}
@@ -134,7 +134,8 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
final idToDelete = <String>{};
for (final entry in assetsByAlbums.entries) {
for (final asset in entry.value) {
for (final record in entry.value) {
final asset = record.asset;
idToDelete.add(asset.id);
companions.add(
TrashedLocalAssetEntityCompanion(
@@ -264,32 +265,6 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
});
}
Future<Map<String, List<LocalAsset>>> getToTrash() async {
final result = <String, List<LocalAsset>>{};
final rows =
await (_db.select(_db.localAlbumAssetEntity).join([
innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id)),
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
leftOuterJoin(
_db.remoteAssetEntity,
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum),
),
])..where(
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
_db.remoteAssetEntity.deletedAt.isNotNull(),
))
.get();
for (final row in rows) {
final albumId = row.readTable(_db.localAlbumAssetEntity).albumId;
final asset = row.readTable(_db.localAssetEntity).toDto();
(result[albumId] ??= <LocalAsset>[]).add(asset);
}
return result;
}
//attempt to reuse existing checksums
Future<Map<String, String>> _getCachedChecksums(Set<String> assetIds) async {
final localChecksumById = <String, String>{};

View File

@@ -0,0 +1,60 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/trash_sync_bottom_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@RoutePage()
class DriftTrashSyncReviewPage extends ConsumerWidget {
const DriftTrashSyncReviewPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) => ProviderScope(
overrides: [
timelineServiceProvider.overrideWith((ref) {
final user = ref.watch(currentUserProvider);
if (user == null) {
throw Exception('User must be logged in to access trash');
}
final timelineService = ref.watch(timelineFactoryProvider).toTrashSyncReview();
ref.onDispose(timelineService.dispose);
return timelineService;
}),
],
child: Timeline(
appBar: SliverAppBar(
title: Text('asset_out_of_sync_title'.tr()),
floating: true,
snap: true,
pinned: true,
centerTitle: true,
elevation: 0,
),
topSliverWidgetHeight: 24,
topSliverWidget: SliverPadding(
padding: const EdgeInsets.all(16.0),
sliver: SliverToBoxAdapter(
child: SizedBox(
height: 72.0,
child: Consumer(
builder: (context, ref, _) {
final outOfSyncCount = ref.watch(outOfSyncAssetsCountProvider).value ?? 0;
return outOfSyncCount > 0
? const Text('asset_out_of_sync_trash_subtitle').tr()
: Center(
child: Text('asset_out_of_sync_trash_subtitle_result', style: context.textTheme.bodyLarge).tr(),
);
},
),
),
),
),
bottomSheet: const TrashSyncBottomBar(),
),
);
}

View File

@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
void showKeepResultToast(BuildContext context, ActionResult result) {
if (!context.mounted) return;
final message = result.success
? 'assets_denied_to_moved_to_trash_count'.t(args: {'count': '${result.count}'})
: 'scaffold_body_error_occurred'.t();
ImmichToast.show(
context: context,
msg: message,
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
/// This deny move to trash action has the following behavior:
/// - Deny moving to the local trash those assets that are in the remote trash.
///
/// This action is used when the asset is selected in multi-selection mode in the trash page
class KeepOnDeviceActionButton extends ConsumerWidget {
final ActionSource source;
final void Function(ActionResult result) onResult;
const KeepOnDeviceActionButton({super.key, required this.source, required this.onResult});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
ref.read(assetViewerProvider.notifier).setControls(false);
final actionNotifier = ref.read(actionProvider.notifier);
final multiSelectNotifier = ref.read(multiSelectProvider.notifier);
final result = await actionNotifier.resolveRemoteTrash(source, isSyncApproved: false);
onResult.call(result);
multiSelectNotifier.reset();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
const iconData = Icons.cloud_off_outlined;
return source == ActionSource.viewer
? BaseActionButton(
maxWidth: 110.0,
iconData: iconData,
label: 'keep'.t(),
onPressed: () => _onTap(context, ref),
)
: TextButton.icon(
icon: const Icon(iconData),
label: Text('keep_on_device'.t(), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
onPressed: () => _onTap(context, ref),
);
}
}

View File

@@ -0,0 +1,96 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
void showTrashResultToast(BuildContext context, ActionResult result) {
if (!context.mounted) return;
final message = result.success
? 'assets_moved_to_trash_count'.t(args: {'count': '${result.count}'})
: 'errors.something_went_wrong'.t();
ImmichToast.show(
context: context,
msg: message,
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.info : ToastType.error,
);
}
/// This move to trash action has the following behavior:
/// - Allows moving to the local trash those assets that are in the remote trash.
///
/// This action is used when the asset is selected in multi-selection mode in the review out-of-sync changes
class MoveToTrashActionButton extends ConsumerWidget {
final ActionSource source;
final void Function(ActionResult result) onResult;
const MoveToTrashActionButton({super.key, required this.source, required this.onResult});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final selectedCount = source == ActionSource.viewer ? 1 : ref.read(multiSelectProvider).selectedAssets.length;
final assetViewerNotifier = ref.read(assetViewerProvider.notifier);
assetViewerNotifier.setControls(false);
final confirmed = await showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text('asset_out_of_sync_trash_confirmation_title'.tr()),
content: Text('asset_out_of_sync_trash_confirmation_text'.t(args: {'count': '$selectedCount'})),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text('cancel'.t(context: context)),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.error),
child: Text('control_bottom_app_bar_trash_from_immich'.tr()),
),
],
);
},
);
if (confirmed != true) {
assetViewerNotifier.setControls(true);
return;
}
final actionNotifier = ref.read(actionProvider.notifier);
final multiSelectNotifier = ref.read(multiSelectProvider.notifier);
final result = await actionNotifier.resolveRemoteTrash(source, isSyncApproved: true);
onResult.call(result);
multiSelectNotifier.reset();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
const iconData = Icons.delete_forever_outlined;
return (source == ActionSource.viewer)
? BaseActionButton(
maxWidth: 100.0,
iconData: iconData,
label: 'delete'.tr(),
onPressed: () => _onTap(context, ref),
)
: TextButton.icon(
icon: Icon(iconData, color: Colors.red[400]),
label: Text(
'control_bottom_app_bar_trash_from_immich'.tr(),
style: TextStyle(fontSize: 14, color: Colors.red[400], fontWeight: FontWeight.bold),
),
onPressed: () => _onTap(context, ref),
);
}
}

View File

@@ -2,15 +2,23 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/keep_on_device_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@@ -34,10 +42,31 @@ class ViewerBottomBar extends ConsumerWidget {
final isInLockedView = ref.watch(inLockedViewProvider);
final serverInfo = ref.watch(serverInfoProvider);
final timelineOrigin = ref.read(timelineServiceProvider).origin;
final isSyncTrashTimeline = timelineOrigin == TimelineOrigin.syncTrash;
// Remove if review is only possible in the syncTrash timeline
final isWaitingForSyncApproval = ref.watch(isWaitingForTrashApprovalProvider(asset.checksum!)).value == true;
final originalTheme = context.themeData;
final actions = <Widget>[
const ShareActionButton(source: ActionSource.viewer),
if (isSyncTrashTimeline || isWaitingForSyncApproval) ...[
KeepOnDeviceActionButton(
source: ActionSource.viewer,
onResult: (result) {
showKeepResultToast(context, result);
_updateView(result, ref);
},
),
MoveToTrashActionButton(
source: ActionSource.viewer,
onResult: (result) {
showTrashResultToast(context, result);
_updateView(result, ref);
},
),
] else ...[
const ShareActionButton(source: ActionSource.viewer),
if (!isInLockedView) ...[
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
@@ -46,10 +75,11 @@ class ViewerBottomBar extends ConsumerWidget {
const EditImageActionButton(),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
if (isOwner) ...[
asset.isLocalOnly
? const DeleteLocalActionButton(source: ActionSource.viewer)
: const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
if (isOwner) ...[
asset.isLocalOnly
? const DeleteLocalActionButton(source: ActionSource.viewer)
: const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
],
],
],
];
@@ -89,4 +119,16 @@ class ViewerBottomBar extends ConsumerWidget {
),
);
}
void _updateView(ActionResult result, WidgetRef ref) {
Future.delayed(Durations.extralong4, () {
if (result.success) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
EventStream.shared.emit(const TimelineReloadEvent());
}
if (ref.context.mounted) {
ref.read(assetViewerProvider.notifier).setControls(true);
}
});
}
}

View File

@@ -9,6 +9,7 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@@ -35,6 +36,7 @@ class ViewerKebabMenu extends ConsumerWidget {
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
final isWaitingForTrashApproval = ref.watch(isWaitingForTrashApprovalProvider(asset.checksum)).value == true;
final actionContext = ActionButtonContext(
asset: asset,
@@ -49,6 +51,7 @@ class ViewerKebabMenu extends ConsumerWidget {
isCasting: isCasting,
timelineOrigin: timelineOrigin,
originalTheme: originalTheme,
isWaitingForTrashApproval: isWaitingForTrashApproval,
);
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref);

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
@@ -12,6 +13,8 @@ import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -44,6 +47,10 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final originalTheme = context.themeData;
final isWaitingForSyncApproval =
ref.read(timelineServiceProvider).origin == TimelineOrigin.syncTrash ||
ref.watch(isWaitingForTrashApprovalProvider(asset.checksum)).value == true;
final actions = <Widget>[
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
if (album != null && album.isActivityEnabled && album.isShared)
@@ -60,9 +67,9 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
},
),
if (asset.hasRemote && isOwner && !asset.isFavorite)
if (asset.hasRemote && isOwner && !asset.isFavorite && !isWaitingForSyncApproval)
const FavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
if (asset.hasRemote && isOwner && asset.isFavorite)
if (asset.hasRemote && isOwner && asset.isFavorite && !isWaitingForSyncApproval)
const UnFavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
ViewerKebabMenu(originalTheme: originalTheme),

View File

@@ -2,17 +2,21 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
class MapBottomSheet extends StatelessWidget {
const MapBottomSheet({super.key});
final Key? sheetKey;
const MapBottomSheet({super.key, this.sheetKey});
@override
Widget build(BuildContext context) {
return BaseBottomSheet(
key: sheetKey,
initialChildSize: 0.25,
maxChildSize: 0.75,
shouldCloseOnMinExtent: false,
@@ -49,7 +53,7 @@ class _ScopedMapTimeline extends StatelessWidget {
return timelineService;
}),
],
child: const Timeline(appBar: null, bottomSheet: null, withScrubber: false),
child: const Timeline(appBar: null, bottomSheet: GeneralBottomSheet(minChildSize: 0.23), withScrubber: false),
);
}
}

View File

@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/keep_on_device_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_trash_action_button.widget.dart';
class TrashSyncBottomBar extends ConsumerWidget {
const TrashSyncBottomBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: SizedBox(
height: 64,
child: Container(
color: context.themeData.canvasColor,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
KeepOnDeviceActionButton(
source: ActionSource.timeline,
onResult: (result) => showKeepResultToast(context, result),
),
MoveToTrashActionButton(
source: ActionSource.timeline,
onResult: (result) => showTrashResultToast(context, result),
),
],
),
),
),
),
);
}
}

View File

@@ -11,6 +11,7 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/presentation/widgets/map/map_utils.dart';
@@ -53,6 +54,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
final _reloadMutex = AsyncMutex();
final _debouncer = Debouncer(interval: const Duration(milliseconds: 500), maxWaitTime: const Duration(seconds: 2));
final ValueNotifier<double> bottomSheetOffset = ValueNotifier(0.25);
final GlobalKey _bottomSheetKey = GlobalKey();
StreamSubscription? _eventSubscription;
@override
@@ -184,7 +186,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
return Stack(
children: [
_Map(initialLocation: widget.initialLocation, onMapCreated: onMapCreated, onMapReady: onMapReady),
_DynamicBottomSheet(bottomSheetOffset: bottomSheetOffset),
_DynamicBottomSheet(bottomSheetOffset: bottomSheetOffset, sheetKey: _bottomSheetKey),
_DynamicMyLocationButton(onZoomToLocation: onZoomToLocation, bottomSheetOffset: bottomSheetOffset),
],
);
@@ -224,8 +226,9 @@ class _Map extends StatelessWidget {
class _DynamicBottomSheet extends StatefulWidget {
final ValueNotifier<double> bottomSheetOffset;
final GlobalKey sheetKey;
const _DynamicBottomSheet({required this.bottomSheetOffset});
const _DynamicBottomSheet({required this.bottomSheetOffset, required this.sheetKey});
@override
State<_DynamicBottomSheet> createState() => _DynamicBottomSheetState();
@@ -236,10 +239,13 @@ class _DynamicBottomSheetState extends State<_DynamicBottomSheet> {
Widget build(BuildContext context) {
return NotificationListener<DraggableScrollableNotification>(
onNotification: (notification) {
widget.bottomSheetOffset.value = notification.extent;
return true;
final sheet = notification.context.findAncestorWidgetOfExactType<BaseBottomSheet>();
if (sheet?.key == widget.sheetKey) {
widget.bottomSheetOffset.value = notification.extent;
}
return false;
},
child: const MapBottomSheet(),
child: MapBottomSheet(sheetKey: widget.sheetKey),
);
}
}

View File

@@ -469,6 +469,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
ref.read(timelineStateProvider.notifier).setScrolling(true);
},
child: Stack(
clipBehavior: Clip.none,
children: [
timeline,
if (isBottomWidgetVisible)

View File

@@ -2,3 +2,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
final appSettingsServiceProvider = Provider((_) => const AppSettingsService());
final appSettingStreamProvider = StreamProvider.family.autoDispose<bool, AppSettingsEnum<bool>>((ref, setting) {
final service = ref.watch(appSettingsServiceProvider);
return service.watchSetting(setting);
});

View File

@@ -512,6 +512,22 @@ class ActionNotifier extends Notifier<void> {
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> resolveRemoteTrash(ActionSource source, {required bool isSyncApproved}) async {
final selectedChecksums = _getAssets(source).map((a) => a.checksum).nonNulls;
_logger.info('resolveRemoteTrash, selectedChecksums: $selectedChecksums, isSyncApproved: $isSyncApproved');
if (selectedChecksums.isEmpty) {
return const ActionResult(count: 0, success: false, error: 'Failed to select asset(s)');
}
try {
final resolvedCount = await _service.resolveRemoteTrash(selectedChecksums, isSyncApproved: isSyncApproved);
final isSuccess = resolvedCount == selectedChecksums.length;
return ActionResult(count: resolvedCount, success: isSuccess);
} catch (error, stack) {
_logger.severe('Failed to ${isSyncApproved ? 'allow' : 'deny'} to move assets to trash', error, stack);
return ActionResult(count: selectedChecksums.length, success: false, error: error.toString());
}
}
}
extension on Iterable<RemoteAsset> {

View File

@@ -12,6 +12,7 @@ import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
final syncMigrationRepositoryProvider = Provider((ref) => SyncMigrationRepository(ref.watch(driftProvider)));
@@ -22,6 +23,7 @@ final syncStreamServiceProvider = Provider(
syncStreamRepository: ref.watch(syncStreamRepositoryProvider),
localAssetRepository: ref.watch(localAssetRepository),
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
trashSyncRepository: ref.watch(trashSyncRepositoryProvider),
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
storageRepository: ref.watch(storageRepositoryProvider),
syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider),
@@ -39,6 +41,7 @@ final localSyncServiceProvider = Provider(
localAlbumRepository: ref.watch(localAlbumRepository),
localAssetRepository: ref.watch(localAssetRepository),
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
trashSyncRepository: ref.watch(trashSyncRepositoryProvider),
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
storageRepository: ref.watch(storageRepositoryProvider),
nativeSyncApi: ref.watch(nativeSyncApiProvider),

View File

@@ -1,12 +1,45 @@
import 'package:async/async.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/trash_sync.service.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
typedef TrashedAssetsCount = ({int total, int hashed});
final trashSyncRepositoryProvider = Provider<DriftTrashSyncRepository>(
(ref) => DriftTrashSyncRepository(ref.watch(driftProvider)),
);
final trashedAssetsCountProvider = StreamProvider<TrashedAssetsCount>((ref) {
final repo = ref.watch(trashedLocalAssetRepository);
final total$ = repo.watchCount();
final hashed$ = repo.watchHashedCount();
return StreamZip<int>([total$, hashed$]).map((values) => (total: values[0], hashed: values[1]));
});
final trashSyncServiceProvider = Provider(
(ref) => TrashSyncService(trashSyncRepository: ref.watch(trashSyncRepositoryProvider)),
);
final outOfSyncAssetsCountProvider = StreamProvider<int>((ref) {
final enabledReviewMode = ref.watch(appSettingStreamProvider(AppSettingsEnum.reviewOutOfSyncChangesAndroid));
final service = ref.watch(trashSyncServiceProvider);
return enabledReviewMode.when(
data: (enabled) => enabled ? service.watchPendingApprovalAssetCount() : Stream<int>.value(0),
loading: () => Stream<int>.value(0),
error: (_, __) => Stream<int>.value(0),
);
});
final isWaitingForTrashApprovalProvider = StreamProvider.family<bool, String?>((ref, checksum) {
final enabledReviewMode = ref.watch(appSettingStreamProvider(AppSettingsEnum.reviewOutOfSyncChangesAndroid));
final service = ref.watch(trashSyncServiceProvider);
return enabledReviewMode.when(
data: (enabled) =>
enabled && checksum != null ? service.watchIsAssetApprovalPending(checksum) : Stream.value(false),
loading: () => Stream.value(false),
error: (_, __) => Stream.value(false),
);
});

View File

@@ -61,6 +61,7 @@ import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart'
import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_trash.page.dart';
import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart';
import 'package:immich_mobile/presentation/pages/drift_trash_sync_review.page.dart';
import 'package:immich_mobile/presentation/pages/drift_video.page.dart';
import 'package:immich_mobile/presentation/pages/edit/drift_edit.page.dart';
import 'package:immich_mobile/presentation/pages/local_timeline.page.dart';
@@ -161,6 +162,7 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DriftMemoryRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftFavoriteRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftTrashRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftTrashSyncReviewRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftArchiveRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftLockedFolderRoute.page, guards: [_authGuard, _lockedGuard, _duplicateGuard]),
AutoRoute(page: DriftVideoRoute.page, guards: [_authGuard, _duplicateGuard]),

View File

@@ -1095,6 +1095,22 @@ class DriftTrashRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [DriftTrashSyncReviewPage]
class DriftTrashSyncReviewRoute extends PageRouteInfo<void> {
const DriftTrashSyncReviewRoute({List<PageRouteInfo>? children})
: super(DriftTrashSyncReviewRoute.name, initialChildren: children);
static const String name = 'DriftTrashSyncReviewRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const DriftTrashSyncReviewPage();
},
);
}
/// generated route for
/// [DriftUploadDetailPage]
class DriftUploadDetailRoute extends PageRouteInfo<void> {

View File

@@ -12,17 +12,23 @@ import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/download.repository.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/timezone.dart';
import 'package:immich_mobile/widgets/common/date_time_picker.dart';
import 'package:immich_mobile/widgets/common/location_picker.dart';
import 'package:logging/logging.dart';
import 'package:maplibre_gl/maplibre_gl.dart' as maplibre;
final actionServiceProvider = Provider<ActionService>(
@@ -33,8 +39,12 @@ final actionServiceProvider = Provider<ActionService>(
ref.watch(driftAlbumApiRepositoryProvider),
ref.watch(remoteAlbumRepository),
ref.watch(trashedLocalAssetRepository),
ref.watch(trashSyncRepositoryProvider),
ref.watch(assetMediaRepositoryProvider),
ref.watch(downloadRepositoryProvider),
ref.watch(storageRepositoryProvider),
ref.watch(localFilesManagerRepositoryProvider),
Logger('ActionService'),
),
);
@@ -45,8 +55,12 @@ class ActionService {
final DriftAlbumApiRepository _albumApiRepository;
final DriftRemoteAlbumRepository _remoteAlbumRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final DriftTrashSyncRepository _trashSyncRepository;
final AssetMediaRepository _assetMediaRepository;
final DownloadRepository _downloadRepository;
final StorageRepository _storageRepository;
final LocalFilesManagerRepository _localFilesManager;
final Logger _logger;
const ActionService(
this._assetApiRepository,
@@ -55,8 +69,12 @@ class ActionService {
this._albumApiRepository,
this._remoteAlbumRepository,
this._trashedLocalAssetRepository,
this._trashSyncRepository,
this._assetMediaRepository,
this._downloadRepository,
this._storageRepository,
this._localFilesManager,
this._logger,
);
Future<void> shareLink(List<String> remoteIds, BuildContext context) async {
@@ -267,4 +285,40 @@ class ActionService {
}
return deletedIds.length;
}
Future<int> resolveRemoteTrash(Iterable<String> trashedChecksums, {required bool isSyncApproved}) async {
if (!isSyncApproved) {
await _trashSyncRepository.updateApproves(trashedChecksums, false);
return trashedChecksums.length;
}
final assetsToTrash = await _localAssetRepository.getRemoteTrashedLocalAssets(trashedChecksums);
if (assetsToTrash.isEmpty) {
// No localAssetEntity found; close review to avoid re-showing the same items.
await _trashSyncRepository.updateApproves(trashedChecksums, true);
return 0;
}
final mediaUrls = await Future.wait(
assetsToTrash.map((e) => _storageRepository.getAssetEntityForAsset(e.asset).then((e) => e?.getMediaUrl())),
);
final trashUrls = mediaUrls.nonNulls;
_logger.info("Moving assets to trash: ${trashUrls.join(", ")}");
if (trashUrls.isNotEmpty) {
final isMoved = await _localFilesManager.moveToTrash(trashUrls.toList());
if (!isMoved) {
return 0;
}
}
await _trashSyncRepository.updateApproves(trashedChecksums, true);
final trashedAssetsMap = Map<String, DateTime>.fromEntries(
assetsToTrash.map((e) => MapEntry(e.asset.checksum!, e.remoteDeletedAt)),
);
final assetsByAlbum = await _localAssetRepository.getAssetsFromBackupAlbums(trashedAssetsMap);
await _trashedLocalAssetRepository.trashLocalAssets(assetsByAlbum);
return trashUrls.length;
}
}

View File

@@ -30,6 +30,7 @@ enum AppSettingsEnum<T> {
selectedAlbumSortOrder<int>(StoreKey.selectedAlbumSortOrder, "selectedAlbumSortOrder", 2),
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
reviewOutOfSyncChangesAndroid<bool>(StoreKey.reviewOutOfSyncChangesAndroid, null, false),
logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5
preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false),
loopVideo<bool>(StoreKey.loopVideo, "loopVideo", true),
@@ -78,4 +79,11 @@ class AppSettingsService {
Future<void> setSetting<T>(AppSettingsEnum<T> setting, T value) {
return Store.put(setting.storeKey, value);
}
Stream<T> watchSetting<T>(AppSettingsEnum<T> setting) async* {
yield getSetting<T>(setting);
await for (final dynamic value in Store.watch(setting.storeKey)) {
yield (value as T?) ?? setting.defaultValue;
}
}
}

View File

@@ -12,11 +12,13 @@ class ImmichTheme {
ThemeData getThemeData({required ColorScheme colorScheme, required Locale locale}) {
final isDark = colorScheme.brightness == Brightness.dark;
final warningColor = isDark ? const Color(0xFFF3BC6A) : const Color(0xFFC47A00);
final onWarningColor = isDark ? Colors.black : Colors.white;
return ThemeData(
useMaterial3: true,
brightness: colorScheme.brightness,
colorScheme: colorScheme,
colorScheme: colorScheme.copyWith(tertiary: warningColor, onTertiary: onWarningColor),
primaryColor: colorScheme.primary,
hintColor: colorScheme.onSurfaceSecondary,
focusColor: colorScheme.primary,

View File

@@ -46,6 +46,7 @@ class ActionButtonContext {
final TimelineOrigin timelineOrigin;
final ThemeData? originalTheme;
final int selectedCount;
final bool isWaitingForTrashApproval;
const ActionButtonContext({
required this.asset,
@@ -61,6 +62,7 @@ class ActionButtonContext {
this.timelineOrigin = TimelineOrigin.main,
this.originalTheme,
this.selectedCount = 1,
this.isWaitingForTrashApproval = false,
});
}
@@ -100,7 +102,8 @@ enum ActionButtonType {
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote && //
!context.isArchived,
!context.isArchived &&
!context.isWaitingForTrashApproval,
ActionButtonType.unarchive =>
context.isOwner && //
!context.isInLockedView && //
@@ -114,27 +117,31 @@ enum ActionButtonType {
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote && //
context.isTrashEnabled,
context.isTrashEnabled &&
!context.isWaitingForTrashApproval,
ActionButtonType.deletePermanent =>
context.isOwner && //
context.asset.hasRemote && //
!context.isTrashEnabled ||
context.isInLockedView,
context.isInLockedView && !context.isWaitingForTrashApproval,
ActionButtonType.delete =>
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote,
context.asset.hasRemote &&
!context.isWaitingForTrashApproval,
ActionButtonType.moveToLockFolder =>
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote,
context.asset.hasRemote &&
!context.isWaitingForTrashApproval,
ActionButtonType.removeFromLockFolder =>
context.isOwner && //
context.isInLockedView && //
context.asset.hasRemote,
ActionButtonType.deleteLocal =>
!context.isInLockedView && //
context.asset.hasLocal,
context.asset.hasLocal &&
!context.isWaitingForTrashApproval,
ActionButtonType.upload =>
!context.isInLockedView && //
context.asset.storage == AssetState.local,
@@ -171,6 +178,7 @@ enum ActionButtonType {
context.timelineOrigin != TimelineOrigin.lockedFolder &&
context.timelineOrigin != TimelineOrigin.archive &&
context.timelineOrigin != TimelineOrigin.localAlbum &&
context.timelineOrigin != TimelineOrigin.syncTrash &&
context.isOwner,
ActionButtonType.cast => context.isCasting || context.asset.hasRemote,
};

View File

@@ -6,11 +6,13 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/server_info/server_disk_info.model.dart';
import 'package:immich_mobile/pages/common/settings.page.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/locale_provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
@@ -68,19 +70,24 @@ class ImmichAppBarDialog extends HookConsumerWidget {
);
}
buildActionButton(IconData icon, String text, Function() onTap, {Widget? trailing}) {
buildActionButton(IconData icon, String text, Function() onTap, {Widget? trailing, Color? btnColor}) {
return ListTile(
dense: true,
visualDensity: VisualDensity.standard,
contentPadding: const EdgeInsets.only(left: 30, right: 30),
minLeadingWidth: 40,
leading: SizedBox(child: Icon(icon, color: theme.textTheme.labelLarge?.color?.withAlpha(250), size: 20)),
leading: SizedBox(
child: Icon(icon, color: btnColor ?? theme.textTheme.labelLarge?.color?.withAlpha(250), size: 20),
),
title: Text(
text,
style: theme.textTheme.labelLarge?.copyWith(color: theme.textTheme.labelLarge?.color?.withAlpha(250)),
style: theme.textTheme.labelLarge?.copyWith(
color: btnColor ?? theme.textTheme.labelLarge?.color?.withAlpha(250),
),
).tr(),
onTap: onTap,
trailing: trailing,
iconColor: btnColor,
);
}
@@ -96,6 +103,25 @@ class ImmichAppBarDialog extends HookConsumerWidget {
);
}
Widget buildOutOfSyncButton() {
return Consumer(
builder: (context, ref, _) {
final outOfSyncCount = ref.watch(outOfSyncAssetsCountProvider).value ?? 0;
if (outOfSyncCount == 0) {
return const SizedBox.shrink();
}
final btnColor = theme.colorScheme.tertiary;
return buildActionButton(
Icons.warning_amber_rounded,
'review_out_of_sync_changes'.t(),
() => context.pushRoute(const DriftTrashSyncReviewRoute()),
trailing: Text('($outOfSyncCount)', style: theme.textTheme.labelLarge?.copyWith(color: btnColor)),
btnColor: btnColor,
);
},
);
}
buildAppLogButton() {
return buildActionButton(
Icons.assignment_outlined,
@@ -269,6 +295,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
],
),
),
buildOutOfSyncButton(),
if (isReadonlyModeEnabled) buildReadonlyMessage(),
buildAppLogButton(),
buildFreeUpSpaceButton(),

View File

@@ -13,6 +13,7 @@ import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/sync_status.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
@@ -112,6 +113,7 @@ class _ProfileIndicator extends ConsumerWidget {
// TODO: remove this when update Flutter version newer than 3.35.7
final isIpad = defaultTargetPlatform == TargetPlatform.iOS && !context.isMobile;
final outOfSyncCount = ref.watch(outOfSyncAssetsCountProvider).value ?? 0;
void toggleReadonlyMode() {
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
ref.read(readonlyModeProvider.notifier).toggleReadonlyMode();
@@ -148,7 +150,7 @@ class _ProfileIndicator extends ConsumerWidget {
),
backgroundColor: Colors.transparent,
alignment: Alignment.bottomRight,
isLabelVisible: versionWarningPresent,
isLabelVisible: versionWarningPresent || outOfSyncCount > 0,
offset: const Offset(-2, -12),
child: user == null
? const Icon(Icons.face_outlined, size: widgetSize)

View File

@@ -7,6 +7,7 @@ import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
@@ -15,8 +16,10 @@ import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custom_proxy_headers_settings.dart';
import 'package:immich_mobile/widgets/settings/settings_action_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_radio_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
import 'package:immich_mobile/widgets/settings/ssl_client_cert_settings.dart';
import 'package:logging/logging.dart';
@@ -27,9 +30,7 @@ class AdvancedSettings extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final advancedTroubleshooting = useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
final isManageMediaSupported = useState(false);
final manageMediaAndroidPermission = useState(false);
final levelId = useAppSettingsState(AppSettingsEnum.logLevel);
final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage);
final readonlyModeEnabled = useAppSettingsState(AppSettingsEnum.readonlyModeEnabled);
@@ -51,11 +52,6 @@ class AdvancedSettings extends HookConsumerWidget {
useEffect(() {
() async {
isManageMediaSupported.value = await checkAndroidVersion();
if (isManageMediaSupported.value) {
manageMediaAndroidPermission.value = await ref
.read(localFilesManagerRepositoryProvider)
.hasManageMediaPermission();
}
}();
return null;
}, []);
@@ -67,36 +63,7 @@ class AdvancedSettings extends HookConsumerWidget {
title: "advanced_settings_troubleshooting_title".tr(),
subtitle: "advanced_settings_troubleshooting_subtitle".tr(),
),
if (isManageMediaSupported.value)
Column(
children: [
SettingsSwitchListTile(
enabled: true,
valueNotifier: manageLocalMediaAndroid,
title: "advanced_settings_sync_remote_deletions_title".tr(),
subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(),
onChanged: (value) async {
if (value) {
final result = await ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
manageLocalMediaAndroid.value = result;
manageMediaAndroidPermission.value = result;
}
},
),
SettingsActionTile(
title: "manage_media_access_title".tr(),
statusText: manageMediaAndroidPermission.value ? "allowed".tr() : "not_allowed".tr(),
subtitle: "manage_media_access_rationale".tr(),
statusColor: manageLocalMediaAndroid.value && !manageMediaAndroidPermission.value
? const Color.fromARGB(255, 243, 188, 106)
: null,
onActionTap: () async {
final result = await ref.read(localFilesManagerRepositoryProvider).manageMediaPermission();
manageMediaAndroidPermission.value = result;
},
),
],
),
if (isManageMediaSupported.value) const _TrashSyncModeSelector(),
SettingsSliderListTile(
text: "advanced_settings_log_level_title".tr(namedArgs: {'level': logLevel}),
valueNotifier: levelId,
@@ -173,3 +140,120 @@ class AdvancedSettings extends HookConsumerWidget {
return SettingsSubPageScaffold(settings: advancedSettings);
}
}
enum _TrashSyncMode { none, auto, review }
final _manageMediaPermissionProvider = FutureProvider<bool>((ref) async {
return ref.watch(localFilesManagerRepositoryProvider).hasManageMediaPermission();
});
class _TrashSyncModeSelector extends HookConsumerWidget {
const _TrashSyncModeSelector();
@override
Widget build(BuildContext context, WidgetRef ref) {
final autoSyncChanges = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
final reviewOutOfSyncChanges = useAppSettingsState(AppSettingsEnum.reviewOutOfSyncChangesAndroid);
final manageMediaAndroidPermission = ref.watch(_manageMediaPermissionProvider);
final manageMediaAndroidPermissionValue = manageMediaAndroidPermission.valueOrNull;
final selectedTrashSyncMode = autoSyncChanges.value
? _TrashSyncMode.auto
: reviewOutOfSyncChanges.value
? _TrashSyncMode.review
: _TrashSyncMode.none;
Future<void> attemptToEnableSetting(AppSettingsEnum key) async {
final result = await ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
ref.invalidate(_manageMediaPermissionProvider);
if (key == AppSettingsEnum.manageLocalMediaAndroid) {
autoSyncChanges.value = result;
if (result) {
reviewOutOfSyncChanges.value = false;
}
}
if (key == AppSettingsEnum.reviewOutOfSyncChangesAndroid) {
reviewOutOfSyncChanges.value = result;
if (result) {
autoSyncChanges.value = false;
}
}
ref.invalidate(appSettingsServiceProvider);
}
Future<void> handleTrashSyncModeChange(_TrashSyncMode? mode) async {
if (mode == null) {
return;
}
switch (mode) {
case _TrashSyncMode.none:
if (!autoSyncChanges.value && !reviewOutOfSyncChanges.value) {
break;
}
autoSyncChanges.value = false;
reviewOutOfSyncChanges.value = false;
ref.invalidate(appSettingsServiceProvider);
break;
case _TrashSyncMode.auto:
if (autoSyncChanges.value) {
break;
}
await attemptToEnableSetting(AppSettingsEnum.manageLocalMediaAndroid);
break;
case _TrashSyncMode.review:
if (reviewOutOfSyncChanges.value) {
break;
}
await attemptToEnableSetting(AppSettingsEnum.reviewOutOfSyncChangesAndroid);
break;
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SettingsSubTitle(title: "advanced_settings_sync_remote_deletions_selector_title".tr()),
SettingsRadioListTile(
groups: [
SettingsRadioGroup(
title: 'off'.tr(),
subtitle: 'advanced_settings_sync_remote_deletions_off_subtitle'.tr(),
value: _TrashSyncMode.none,
),
SettingsRadioGroup(
title: 'advanced_settings_sync_remote_deletions_title'.tr(),
subtitle: 'advanced_settings_sync_remote_deletions_subtitle'.tr(),
value: _TrashSyncMode.auto,
),
SettingsRadioGroup(
title: 'advanced_settings_review_remote_deletions_title'.tr(),
subtitle: 'advanced_settings_review_remote_deletions_subtitle'.tr(),
value: _TrashSyncMode.review,
),
],
groupBy: selectedTrashSyncMode,
onRadioChanged: (mode) => handleTrashSyncModeChange(mode),
),
SettingsActionTile(
title: "manage_media_access_title".tr(),
statusText: manageMediaAndroidPermissionValue == null
? null
: manageMediaAndroidPermissionValue == true
? "allowed".tr()
: "not_allowed".tr(),
subtitle: "manage_media_access_rationale".tr(),
statusColor:
manageMediaAndroidPermissionValue == false && (autoSyncChanges.value || reviewOutOfSyncChanges.value)
? const Color.fromARGB(255, 243, 188, 106)
: null,
onActionTap: () async {
await ref.read(localFilesManagerRepositoryProvider).manageMediaPermission();
ref.invalidate(_manageMediaPermissionProvider);
},
),
],
);
}
}

View File

@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@@ -354,8 +355,10 @@ class _SyncStatsCounts extends ConsumerWidget {
),
),
// To be removed once the experimental feature is stable
if (CurrentPlatform.isAndroid &&
appSettingsService.getSetting<bool>(AppSettingsEnum.manageLocalMediaAndroid)) ...[
if ((kDebugMode || kProfileMode) &&
CurrentPlatform.isAndroid &&
(appSettingsService.getSetting<bool>(AppSettingsEnum.manageLocalMediaAndroid) ||
appSettingsService.getSetting<bool>(AppSettingsEnum.reviewOutOfSyncChangesAndroid))) ...[
SettingGroupTitle(title: "trash".t(context: context)),
Consumer(
builder: (context, ref, _) {

View File

@@ -1,11 +1,13 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
class SettingsRadioGroup<T> {
final String title;
final String? subtitle;
final T value;
const SettingsRadioGroup({required this.title, required this.value});
const SettingsRadioGroup({required this.title, this.subtitle, required this.value});
}
class SettingsRadioListTile<T> extends StatelessWidget {
@@ -28,6 +30,12 @@ class SettingsRadioListTile<T> extends StatelessWidget {
dense: true,
activeColor: context.primaryColor,
title: Text(g.title, style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
subtitle: g.subtitle != null
? Text(
g.subtitle!,
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
)
: null,
value: g.value,
controlAffinity: ListTileControlAffinity.trailing,
),

View File

@@ -3,6 +3,7 @@ import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset/remote_deleted_local_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/local_sync.service.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
@@ -12,6 +13,7 @@ import 'package:immich_mobile/infrastructure/repositories/local_album.repository
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
@@ -28,6 +30,7 @@ void main() {
late DriftLocalAlbumRepository mockLocalAlbumRepository;
late DriftLocalAssetRepository mockLocalAssetRepository;
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository;
late DriftTrashSyncRepository mockTrashSyncRepo;
late LocalFilesManagerRepository mockLocalFilesManager;
late StorageRepository mockStorageRepository;
late MockNativeSyncApi mockNativeSyncApi;
@@ -36,6 +39,9 @@ void main() {
setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized();
debugDefaultTargetPlatformOverride = TargetPlatform.android;
registerFallbackValue(LocalAssetStub.image1);
registerFallbackValue(<LocalAsset>[]);
registerFallbackValue(RemoteDeletedLocalAsset(asset: LocalAssetStub.image1, remoteDeletedAt: DateTime(2025, 1, 1)));
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: DriftStoreRepository(db));
@@ -51,6 +57,7 @@ void main() {
mockLocalAlbumRepository = MockLocalAlbumRepository();
mockLocalAssetRepository = MockLocalAssetRepository();
mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository();
mockTrashSyncRepo = MockTrashSyncRepository();
mockLocalFilesManager = MockLocalFilesManagerRepository();
mockStorageRepository = MockStorageRepository();
mockNativeSyncApi = MockNativeSyncApi();
@@ -62,10 +69,17 @@ void main() {
when(() => mockNativeSyncApi.getTrashedAssets()).thenAnswer((_) async => {});
when(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).thenAnswer((_) async {});
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []);
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {});
when(
() => mockLocalAssetRepository.getToTrash(),
).thenAnswer((_) async => <String, List<RemoteDeletedLocalAsset>>{});
when(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any())).thenAnswer((_) async {});
when(() => mockTrashedLocalAssetRepository.trashLocalAsset(any())).thenAnswer((_) async {});
when(() => mockTrashedLocalAssetRepository.trashLocalAssets(any())).thenAnswer((_) async {});
when(() => mockLocalFilesManager.moveToTrash(any<List<String>>())).thenAnswer((_) async => true);
when(() => mockStorageRepository.getAssetEntityForAsset(any())).thenAnswer((_) async => null);
when(
() => mockTrashSyncRepo.upsertReviewCandidates(any<Iterable<RemoteDeletedLocalAsset>>()),
).thenAnswer((_) async {});
when(() => mockTrashSyncRepo.deleteOutdatedThrottled()).thenAnswer((_) async => 0);
sut = LocalSyncService(
localAlbumRepository: mockLocalAlbumRepository,
@@ -74,9 +88,12 @@ void main() {
localFilesManager: mockLocalFilesManager,
storageRepository: mockStorageRepository,
nativeSyncApi: mockNativeSyncApi,
trashSyncRepository: mockTrashSyncRepo,
);
await Store.clear();
await Store.put(StoreKey.manageLocalMediaAndroid, false);
await Store.put(StoreKey.reviewOutOfSyncChangesAndroid, false);
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false);
});
@@ -84,6 +101,9 @@ void main() {
test('invokes syncTrashedAssets when Android flag enabled and permission granted', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
when(
() => mockLocalAssetRepository.getToTrash(),
).thenAnswer((_) async => <String, List<RemoteDeletedLocalAsset>>{});
await sut.sync();
@@ -102,6 +122,7 @@ void main() {
test('skips syncTrashedAssets when MANAGE_MEDIA permission absent', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
await Store.put(StoreKey.reviewOutOfSyncChangesAndroid, true);
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false);
await sut.sync();
@@ -113,8 +134,8 @@ void main() {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
await Store.put(StoreKey.manageLocalMediaAndroid, true);
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
await Store.put(StoreKey.manageLocalMediaAndroid, false);
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false);
await sut.sync();
@@ -123,6 +144,39 @@ void main() {
});
group('LocalSyncService - syncTrashedAssets behavior', () {
test('review mode records candidates and deletes outdated once', () async {
await Store.put(StoreKey.reviewOutOfSyncChangesAndroid, true);
expect(Store.get(StoreKey.reviewOutOfSyncChangesAndroid, false), isTrue);
final platformAsset = PlatformAsset(
id: 'remote-id',
name: 'remote.jpg',
type: AssetType.image.index,
durationMs: 0,
orientation: 0,
isFavorite: false,
playbackStyle: PlatformAssetPlaybackStyle.image,
);
final localAssetToTrash = LocalAssetStub.image2.copyWith(id: 'local-trash', checksum: 'checksum-review');
final remoteDeletedAt = DateTime(2025, 1, 1);
when(() => mockLocalAssetRepository.getToTrash()).thenAnswer(
(_) async => {
'album-a': [RemoteDeletedLocalAsset(asset: localAssetToTrash, remoteDeletedAt: remoteDeletedAt)],
},
);
await sut.processTrashedAssets({
'album-a': [platformAsset],
});
verify(() => mockLocalAssetRepository.getToTrash()).called(1);
verify(() => mockTrashSyncRepo.upsertReviewCandidates(any<Iterable<RemoteDeletedLocalAsset>>())).called(1);
verify(() => mockTrashSyncRepo.deleteOutdatedThrottled()).called(1);
verifyNever(() => mockLocalFilesManager.moveToTrash(any()));
verifyNever(() => mockTrashedLocalAssetRepository.trashLocalAssets(any()));
});
test('processes trashed snapshot, restores assets, and trashes local files', () async {
final platformAsset = PlatformAsset(
id: 'remote-id',
@@ -131,7 +185,7 @@ void main() {
durationMs: 0,
orientation: 0,
isFavorite: false,
playbackStyle: PlatformAssetPlaybackStyle.image
playbackStyle: PlatformAssetPlaybackStyle.image,
);
final assetsToRestore = [LocalAssetStub.image1];
@@ -144,9 +198,10 @@ void main() {
});
final localAssetToTrash = LocalAssetStub.image2.copyWith(id: 'local-trash', checksum: 'checksum-trash');
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer(
final remoteDeletedAt = DateTime(2025, 1, 1);
when(() => mockLocalAssetRepository.getToTrash()).thenAnswer(
(_) async => {
'album-a': [localAssetToTrash],
'album-a': [RemoteDeletedLocalAsset(asset: localAssetToTrash, remoteDeletedAt: remoteDeletedAt)],
},
);
@@ -166,7 +221,7 @@ void main() {
expect(trashedEntry.albumId, 'album-a');
expect(trashedEntry.asset.id, platformAsset.id);
expect(trashedEntry.asset.name, platformAsset.name);
verify(() => mockTrashedLocalAssetRepository.getToTrash()).called(1);
verify(() => mockLocalAssetRepository.getToTrash()).called(1);
verify(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).called(1);
verify(() => mockTrashedLocalAssetRepository.applyRestoredAssets(restoredIds)).called(1);
@@ -175,10 +230,13 @@ void main() {
final moveArgs = verify(() => mockLocalFilesManager.moveToTrash(captureAny())).captured.single as List<String>;
expect(moveArgs, ['content://local-trash']);
final trashArgs =
verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single
as Map<String, List<LocalAsset>>;
verify(() => mockTrashedLocalAssetRepository.trashLocalAssets(captureAny())).captured.single
as Map<String, List<RemoteDeletedLocalAsset>>;
expect(trashArgs.keys, ['album-a']);
expect(trashArgs['album-a'], [localAssetToTrash]);
expect(trashArgs['album-a']!.length, 1);
final trashedRecord = trashArgs['album-a']!.single;
expect(trashedRecord.asset, localAssetToTrash);
expect(trashedRecord.remoteDeletedAt, remoteDeletedAt);
});
test('does not attempt restore when repository has no assets to restore', () async {
@@ -195,12 +253,14 @@ void main() {
});
test('does not move local assets when repository finds nothing to trash', () async {
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {});
when(
() => mockLocalAssetRepository.getToTrash(),
).thenAnswer((_) async => <String, List<RemoteDeletedLocalAsset>>{});
await sut.processTrashedAssets({});
verifyNever(() => mockLocalFilesManager.moveToTrash(any()));
verifyNever(() => mockTrashedLocalAssetRepository.trashLocalAsset(any()));
verifyNever(() => mockTrashedLocalAssetRepository.trashLocalAssets(any()));
});
});
@@ -215,7 +275,7 @@ void main() {
isFavorite: false,
createdAt: 1700000000,
updatedAt: 1732000000,
playbackStyle: PlatformAssetPlaybackStyle.image
playbackStyle: PlatformAssetPlaybackStyle.image,
);
final localAsset = platformAsset.toLocalAsset();

View File

@@ -5,6 +5,7 @@ import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset/remote_deleted_local_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
@@ -16,6 +17,7 @@ import 'package:immich_mobile/infrastructure/repositories/storage.repository.dar
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/utils/semver.dart';
@@ -52,6 +54,7 @@ void main() {
late SyncApiRepository mockSyncApiRepo;
late DriftLocalAssetRepository mockLocalAssetRepo;
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepo;
late DriftTrashSyncRepository mockTrashSyncRepo;
late LocalFilesManagerRepository mockLocalFilesManagerRepo;
late StorageRepository mockStorageRepo;
late MockApiService mockApi;
@@ -68,9 +71,10 @@ void main() {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
registerFallbackValue(LocalAssetStub.image1);
registerFallbackValue(const SemVer(major: 2, minor: 5, patch: 0));
registerFallbackValue(RemoteDeletedLocalAsset(asset: LocalAssetStub.image1, remoteDeletedAt: DateTime(2025, 1, 1)));
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: DriftStoreRepository(db));
await StoreService.init(storeRepository: DriftStoreRepository(db), listenUpdates: false);
});
tearDownAll(() async {
@@ -87,6 +91,7 @@ void main() {
mockLocalAssetRepo = MockLocalAssetRepository();
mockTrashedLocalAssetRepo = MockTrashedLocalAssetRepository();
mockLocalFilesManagerRepo = MockLocalFilesManagerRepository();
mockTrashSyncRepo = MockTrashSyncRepository();
mockStorageRepo = MockStorageRepository();
mockAbortCallbackWrapper = _MockAbortCallbackWrapper();
mockResetCallbackWrapper = _MockAbortCallbackWrapper();
@@ -161,12 +166,15 @@ void main() {
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
localFilesManager: mockLocalFilesManagerRepo,
storageRepository: mockStorageRepo,
trashSyncRepository: mockTrashSyncRepo,
api: mockApi,
syncMigrationRepository: mockSyncMigrationRepo,
);
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((_) async => {});
when(() => mockTrashedLocalAssetRepo.trashLocalAsset(any())).thenAnswer((_) async {});
when(
() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any<Map<String, DateTime>>()),
).thenAnswer((_) async => <String, List<RemoteDeletedLocalAsset>>{});
when(() => mockTrashedLocalAssetRepo.trashLocalAssets(any())).thenAnswer((_) async {});
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => []);
when(() => mockTrashedLocalAssetRepo.applyRestoredAssets(any())).thenAnswer((_) async {});
hasManageMediaPermission = false;
@@ -174,7 +182,9 @@ void main() {
when(() => mockLocalFilesManagerRepo.moveToTrash(any())).thenAnswer((_) async => true);
when(() => mockLocalFilesManagerRepo.restoreAssetsFromTrash(any())).thenAnswer((_) async => []);
when(() => mockStorageRepo.getAssetEntityForAsset(any())).thenAnswer((_) async => null);
when(() => mockTrashSyncRepo.upsertReviewCandidates(any())).thenAnswer((_) async {});
await Store.put(StoreKey.manageLocalMediaAndroid, false);
await Store.put(StoreKey.reviewOutOfSyncChangesAndroid, false);
});
Future<void> simulateEvents(List<SyncEvent> events) async {
@@ -246,6 +256,7 @@ void main() {
cancelChecker: cancellationChecker.call,
api: mockApi,
syncMigrationRepository: mockSyncMigrationRepo,
trashSyncRepository: mockTrashSyncRepo,
);
await sut.sync();
@@ -287,6 +298,7 @@ void main() {
cancelChecker: cancellationChecker.call,
api: mockApi,
syncMigrationRepository: mockSyncMigrationRepo,
trashSyncRepository: mockTrashSyncRepo,
);
await sut.sync();
@@ -415,12 +427,21 @@ void main() {
remoteId: 'remote-merged',
);
final assetsByAlbum = {
'album-a': [localAsset],
'album-b': [mergedAsset],
'album-a': [RemoteDeletedLocalAsset(asset: localAsset, remoteDeletedAt: DateTime(2025, 5, 1))],
'album-b': [RemoteDeletedLocalAsset(asset: mergedAsset, remoteDeletedAt: DateTime(2025, 5, 2))],
};
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((invocation) async {
final Iterable<String> requestedChecksums = invocation.positionalArguments.first as Iterable<String>;
expect(requestedChecksums.toSet(), equals({'checksum-local', 'checksum-merged', 'checksum-remote-only'}));
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any<Map<String, DateTime>>())).thenAnswer((
invocation,
) async {
final Map<String, DateTime> trashedAssetsMap = invocation.positionalArguments.first as Map<String, DateTime>;
expect(
trashedAssetsMap,
equals({
localAsset.checksum!: DateTime(2025, 5, 1),
mergedAsset.checksum!: DateTime(2025, 5, 2),
'checksum-remote-only': DateTime(2025, 5, 3),
}),
);
return assetsByAlbum;
});
@@ -461,10 +482,51 @@ void main() {
await simulateEvents(events);
verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(assetsByAlbum)).called(1);
verify(() => mockTrashedLocalAssetRepo.trashLocalAssets(assetsByAlbum)).called(1);
verify(() => mockSyncApiRepo.ack(['asset-remote-only-3'])).called(1);
});
test("uses review mode without moving assets to trash", () async {
await Store.put(StoreKey.reviewOutOfSyncChangesAndroid, true);
when(() => mockLocalFilesManagerRepo.hasManageMediaPermission()).thenAnswer((_) async => true);
final localAsset = LocalAssetStub.image1.copyWith(id: 'local-only', checksum: 'checksum-review', remoteId: null);
final assetsByAlbum = {
'album-a': [RemoteDeletedLocalAsset(asset: localAsset, remoteDeletedAt: DateTime(2025, 5, 1))],
};
when(
() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any<Map<String, DateTime>>()),
).thenAnswer((_) async => assetsByAlbum);
final events = [
SyncStreamStub.assetTrashed(
id: 'remote-1',
checksum: localAsset.checksum!,
ack: 'asset-remote-review-1',
trashedAt: DateTime(2025, 5, 1),
),
];
await simulateEvents(events);
verify(() => mockTrashSyncRepo.upsertReviewCandidates(any())).called(1);
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAssets(any()));
});
test("does not check MANAGE_MEDIA permission on non-Android platforms", () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
await Store.put(StoreKey.manageLocalMediaAndroid, true);
await Store.put(StoreKey.reviewOutOfSyncChangesAndroid, false);
final events = [SyncStreamStub.assetModified(id: 'remote-1', checksum: 'checksum-1', ack: 'asset-mod-ack-1')];
await simulateEvents(events);
verifyNever(() => mockLocalFilesManagerRepo.hasManageMediaPermission());
});
test("skips device trashing when no local assets match the remote trash payload", () async {
final events = [
SyncStreamStub.assetTrashed(
@@ -477,17 +539,25 @@ void main() {
await simulateEvents(events);
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any<Map<String, DateTime>>())).called(1);
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any()));
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAssets(any()));
});
test("does not request local deletions for permanent remote delete events", () async {
test("requests local deletions lookup by remote ids for permanent remote delete events", () async {
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any<Map<String, DateTime>>())).thenAnswer((
invocation,
) async {
final lookup = invocation.positionalArguments.first as Map<String, DateTime>;
expect(lookup.keys.toSet(), equals({'remote-asset'}));
return {};
});
final events = [SyncStreamStub.assetDeleteV1];
await simulateEvents(events);
verifyNever(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any()));
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any<Map<String, DateTime>>())).called(1);
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1);
});

View File

@@ -0,0 +1,162 @@
import 'package:drift/drift.dart' hide isNotNull, isNull;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset/remote_deleted_local_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import '../../fixtures/asset.stub.dart';
void main() {
late Drift db;
late DriftTrashSyncRepository repository;
setUp(() async {
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
repository = DriftTrashSyncRepository(db);
await db.into(db.userEntity).insert(
UserEntityCompanion.insert(id: 'user-1', name: 'user-1', email: 'user-1@example.com'),
);
});
tearDown(() async {
await db.close();
});
Future<void> insertTrashSync({
required String checksum,
bool? isSyncApproved,
required DateTime remoteDeletedAt,
}) async {
await db.into(db.trashSyncEntity).insert(
TrashSyncEntityCompanion.insert(
checksum: checksum,
isSyncApproved: Value(isSyncApproved),
remoteDeletedAt: remoteDeletedAt,
),
);
}
Future<void> insertRemoteAsset({required String checksum, DateTime? deletedAt}) async {
final now = DateTime(2025, 1, 1);
await db.into(db.remoteAssetEntity).insert(
RemoteAssetEntityCompanion.insert(
id: 'remote-$checksum',
checksum: checksum,
name: 'remote-$checksum.jpg',
ownerId: 'user-1',
type: AssetType.image,
createdAt: Value(now),
updatedAt: Value(now),
visibility: AssetVisibility.timeline,
deletedAt: Value(deletedAt),
),
);
}
Future<void> insertLocalAsset({required String checksum}) async {
final now = DateTime(2025, 1, 1);
await db.into(db.localAssetEntity).insert(
LocalAssetEntityCompanion.insert(
id: 'local-$checksum',
checksum: Value(checksum),
name: 'local-$checksum.jpg',
type: AssetType.image,
createdAt: Value(now),
updatedAt: Value(now),
),
);
}
Future<void> insertTrashedLocalAsset({required String checksum}) async {
final now = DateTime(2025, 1, 1);
await db.into(db.trashedLocalAssetEntity).insert(
TrashedLocalAssetEntityCompanion.insert(
id: 'trashed-$checksum',
albumId: 'album-$checksum',
name: 'trashed-$checksum.jpg',
type: AssetType.image,
checksum: Value(checksum),
createdAt: Value(now),
updatedAt: Value(now),
source: TrashOrigin.localSync,
),
);
}
group('upsertReviewCandidates', () {
test('inserts new entries and updates rejected ones when newer', () async {
final oldTime = DateTime(2025, 1, 1);
final newTime = DateTime(2025, 1, 2);
await insertTrashSync(checksum: 'approved', isSyncApproved: true, remoteDeletedAt: oldTime);
await insertTrashSync(checksum: 'rejected', isSyncApproved: false, remoteDeletedAt: oldTime);
await insertTrashSync(checksum: 'rejected-newer', isSyncApproved: false, remoteDeletedAt: newTime);
final items = [
RemoteDeletedLocalAsset(asset: LocalAssetStub.image1.copyWith(checksum: 'new'), remoteDeletedAt: newTime),
RemoteDeletedLocalAsset(asset: LocalAssetStub.image1.copyWith(checksum: 'rejected'), remoteDeletedAt: newTime),
RemoteDeletedLocalAsset(asset: LocalAssetStub.image1.copyWith(checksum: 'approved'), remoteDeletedAt: newTime),
RemoteDeletedLocalAsset(
asset: LocalAssetStub.image1.copyWith(checksum: 'rejected-newer'), remoteDeletedAt: oldTime),
];
await repository.upsertReviewCandidates(items);
final rows = await db.select(db.trashSyncEntity).get();
final byChecksum = {for (final row in rows) row.checksum: row};
expect(byChecksum['new'], isNotNull);
expect(byChecksum['new']!.isSyncApproved, isNull);
expect(byChecksum['new']?.remoteDeletedAt, newTime);
expect(byChecksum['rejected'], isNotNull);
expect(byChecksum['rejected']!.isSyncApproved, isNull);
expect(byChecksum['rejected']?.remoteDeletedAt, newTime);
expect(byChecksum['approved']?.isSyncApproved, isTrue);
expect(byChecksum['approved']?.remoteDeletedAt, oldTime);
expect(byChecksum['rejected-newer']?.isSyncApproved, isFalse);
expect(byChecksum['rejected-newer']?.remoteDeletedAt, newTime);
});
});
group('deleteOutdated', () {
test('removes matched and orphaned entries', () async {
final now = DateTime(2025, 1, 1);
await insertRemoteAsset(checksum: 'alive-remote', deletedAt: null);
await insertLocalAsset(checksum: 'reject-keep');
await insertTrashedLocalAsset(checksum: 'approve-keep');
await insertTrashedLocalAsset(checksum: 'local-trashed');
await insertTrashSync(checksum: 'alive-remote', isSyncApproved: null, remoteDeletedAt: now);
await insertTrashSync(checksum: 'local-trashed', isSyncApproved: false, remoteDeletedAt: now);
await insertTrashSync(checksum: 'pending-keep', isSyncApproved: null, remoteDeletedAt: now);
await insertTrashSync(checksum: 'reject-orphan', isSyncApproved: false, remoteDeletedAt: now);
await insertTrashSync(checksum: 'reject-keep', isSyncApproved: false, remoteDeletedAt: now);
await insertTrashSync(checksum: 'approve-orphan', isSyncApproved: true, remoteDeletedAt: now);
await insertTrashSync(checksum: 'approve-keep', isSyncApproved: true, remoteDeletedAt: now);
final deleted = await repository.deleteOutdated();
expect(deleted, 4);
final remaining = await db.select(db.trashSyncEntity).get();
final remainingChecksums = remaining.map((row) => row.checksum).toSet();
expect(remainingChecksums, containsAll(['pending-keep', 'reject-keep', 'approve-keep']));
expect(remainingChecksums, isNot(contains('alive-remote')));
expect(remainingChecksums, isNot(contains('local-trashed')));
expect(remainingChecksums, isNot(contains('reject-orphan')));
expect(remainingChecksums, isNot(contains('approve-orphan')));
});
});
}

View File

@@ -9,6 +9,7 @@ import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
@@ -33,6 +34,8 @@ class MockRemoteAssetRepository extends Mock implements RemoteAssetRepository {}
class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {}
class MockTrashSyncRepository extends Mock implements DriftTrashSyncRepository {}
class MockStorageRepository extends Mock implements StorageRepository {}
class MockDriftBackupRepository extends Mock implements DriftBackupRepository {}

View File

@@ -2,6 +2,7 @@ import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/remote_deleted_local_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
@@ -9,9 +10,12 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/repositories/download.repository.dart';
import 'package:immich_mobile/services/action.service.dart';
import 'package:logging/logging.dart';
import 'package:mocktail/mocktail.dart';
import '../fixtures/asset.stub.dart';
import '../infrastructure/repository.mock.dart';
import '../mocks/asset_entity.mock.dart';
import '../repository.mocks.dart';
class MockDownloadRepository extends Mock implements DownloadRepository {}
@@ -25,12 +29,16 @@ void main() {
late MockDriftAlbumApiRepository albumApiRepository;
late MockRemoteAlbumRepository remoteAlbumRepository;
late MockTrashedLocalAssetRepository trashedLocalAssetRepository;
late MockTrashSyncRepository trashSyncRepository;
late MockAssetMediaRepository assetMediaRepository;
late MockDownloadRepository downloadRepository;
late MockStorageRepository storageRepository;
late MockLocalFilesManagerRepository localFilesManagerRepository;
late Drift db;
setUpAll(() async {
registerFallbackValue(LocalAssetStub.image1);
TestWidgetsFlutterBinding.ensureInitialized();
debugDefaultTargetPlatformOverride = TargetPlatform.android;
@@ -51,8 +59,11 @@ void main() {
albumApiRepository = MockDriftAlbumApiRepository();
remoteAlbumRepository = MockRemoteAlbumRepository();
trashedLocalAssetRepository = MockTrashedLocalAssetRepository();
trashSyncRepository = MockTrashSyncRepository();
assetMediaRepository = MockAssetMediaRepository();
downloadRepository = MockDownloadRepository();
storageRepository = MockStorageRepository();
localFilesManagerRepository = MockLocalFilesManagerRepository();
sut = ActionService(
assetApiRepository,
@@ -61,9 +72,17 @@ void main() {
albumApiRepository,
remoteAlbumRepository,
trashedLocalAssetRepository,
trashSyncRepository,
assetMediaRepository,
downloadRepository,
storageRepository,
localFilesManagerRepository,
Logger('ActionServiceTest'),
);
when(() => localAssetRepository.getAssetsFromBackupAlbums(any())).thenAnswer((_) async => {});
when(() => trashedLocalAssetRepository.trashLocalAssets(any())).thenAnswer((_) async {});
when(() => trashSyncRepository.updateApproves(any(), any())).thenAnswer((_) async {});
});
tearDown(() async {
@@ -115,4 +134,127 @@ void main() {
verifyNever(() => localAssetRepository.delete(any()));
});
});
group('ActionService.resolveRemoteTrash', () {
test('updates approvals and returns requested count when disallowed', () async {
when(() => trashSyncRepository.updateApproves(any(), false)).thenAnswer((_) async {});
final result = await sut.resolveRemoteTrash(['checksum-1'], isSyncApproved: false);
expect(result, 1);
verify(() => trashSyncRepository.updateApproves(any(), false)).called(1);
verifyNever(
() => localAssetRepository.getRemoteTrashedLocalAssets(any()),
);
verifyNever(() => localFilesManagerRepository.moveToTrash(any()));
});
test('returns 0 when no local assets match', () async {
when(
() => localAssetRepository.getRemoteTrashedLocalAssets(any()),
).thenAnswer((_) async => []);
when(() => trashSyncRepository.updateApproves(any(), true)).thenAnswer((_) async {});
final result = await sut.resolveRemoteTrash(['checksum-1'], isSyncApproved: true);
expect(result, 0);
verify(
() => localAssetRepository.getRemoteTrashedLocalAssets(any()),
).called(1);
verify(() => trashSyncRepository.updateApproves(any(), true)).called(1);
verifyNever(() => localFilesManagerRepository.moveToTrash(any()));
});
test('closes review when no local files are found', () async {
final localAsset = LocalAssetStub.image1.copyWith(checksum: 'checksum-1');
final remoteDeleted = RemoteDeletedLocalAsset(asset: localAsset, remoteDeletedAt: DateTime(2024, 1, 1));
when(
() => localAssetRepository.getRemoteTrashedLocalAssets(any()),
).thenAnswer((_) async => [remoteDeleted]);
when(() => storageRepository.getAssetEntityForAsset(localAsset)).thenAnswer((_) async => null);
when(() => trashSyncRepository.updateApproves(any(), true)).thenAnswer((_) async {});
final result = await sut.resolveRemoteTrash(['checksum-1'], isSyncApproved: true);
expect(result, 0);
verify(() => trashSyncRepository.updateApproves(any(), true)).called(1);
verifyNever(() => localFilesManagerRepository.moveToTrash(any()));
});
test('moves files to trash and updates approvals on success', () async {
final localAsset = LocalAssetStub.image1.copyWith(checksum: 'checksum-1');
final remoteDeleted = RemoteDeletedLocalAsset(asset: localAsset, remoteDeletedAt: DateTime(2024, 1, 1));
final entity = MockAssetEntity();
when(
() => localAssetRepository.getRemoteTrashedLocalAssets(any()),
).thenAnswer((_) async => [remoteDeleted]);
when(() => storageRepository.getAssetEntityForAsset(localAsset)).thenAnswer((_) async => entity);
when(() => entity.getMediaUrl()).thenAnswer((_) async => 'content://asset-1');
when(() => localFilesManagerRepository.moveToTrash(any())).thenAnswer((_) async => true);
when(() => trashSyncRepository.updateApproves(any(), true)).thenAnswer((_) async {});
final result = await sut.resolveRemoteTrash(['checksum-1'], isSyncApproved: true);
expect(result, 1);
verify(() => localFilesManagerRepository.moveToTrash(['content://asset-1'])).called(1);
verify(() => trashSyncRepository.updateApproves(any(), true)).called(1);
});
test('does not update approvals when move to trash fails', () async {
final localAsset = LocalAssetStub.image1.copyWith(checksum: 'checksum-1');
final remoteDeleted = RemoteDeletedLocalAsset(asset: localAsset, remoteDeletedAt: DateTime(2024, 1, 1));
final entity = MockAssetEntity();
when(
() => localAssetRepository.getRemoteTrashedLocalAssets(any()),
).thenAnswer((_) async => [remoteDeleted]);
when(() => storageRepository.getAssetEntityForAsset(localAsset)).thenAnswer((_) async => entity);
when(() => entity.getMediaUrl()).thenAnswer((_) async => 'content://asset-1');
when(() => localFilesManagerRepository.moveToTrash(any())).thenAnswer((_) async => false);
final result = await sut.resolveRemoteTrash(['checksum-1'], isSyncApproved: true);
expect(result, 0);
verify(() => localFilesManagerRepository.moveToTrash(['content://asset-1'])).called(1);
verifyNever(() => trashSyncRepository.updateApproves(any(), true));
});
test('updates approvals and syncs trash even when no media urls are found', () async {
final localAsset = LocalAssetStub.image1.copyWith(checksum: 'checksum-1');
final remoteDeleted = RemoteDeletedLocalAsset(asset: localAsset, remoteDeletedAt: DateTime(2024, 1, 1));
when(
() => localAssetRepository.getRemoteTrashedLocalAssets(any()),
).thenAnswer((_) async => [remoteDeleted]);
when(() => storageRepository.getAssetEntityForAsset(localAsset)).thenAnswer((_) async => null);
final result = await sut.resolveRemoteTrash(['checksum-1'], isSyncApproved: true);
expect(result, 0);
verifyNever(() => localFilesManagerRepository.moveToTrash(any()));
verify(() => trashSyncRepository.updateApproves(any(), true)).called(1);
verify(() => localAssetRepository.getAssetsFromBackupAlbums(any())).called(1);
verify(() => trashedLocalAssetRepository.trashLocalAssets(any())).called(1);
});
test('builds trashed assets map from remote deletion dates', () async {
final asset1 = LocalAssetStub.image1.copyWith(checksum: 'checksum-1');
final asset2 = LocalAssetStub.image1.copyWith(checksum: 'checksum-2');
final deletedAt1 = DateTime(2024, 1, 1);
final deletedAt2 = DateTime(2024, 2, 2);
final remoteDeleted = [
RemoteDeletedLocalAsset(asset: asset1, remoteDeletedAt: deletedAt1),
RemoteDeletedLocalAsset(asset: asset2, remoteDeletedAt: deletedAt2),
];
when(
() => localAssetRepository.getRemoteTrashedLocalAssets(any()),
).thenAnswer((_) async => remoteDeleted);
when(() => storageRepository.getAssetEntityForAsset(any())).thenAnswer((_) async => null);
final result = await sut.resolveRemoteTrash(['checksum-1', 'checksum-2'], isSyncApproved: true);
expect(result, 0);
final captured = verify(() => localAssetRepository.getAssetsFromBackupAlbums(captureAny())).captured.single
as Map<String, DateTime>;
expect(captured, {'checksum-1': deletedAt1, 'checksum-2': deletedAt2});
});
});
}

View File

@@ -86,6 +86,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
@@ -117,7 +118,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.share.shouldShow(context), isTrue);
@@ -133,7 +135,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.share.shouldShow(context), isTrue);
@@ -152,7 +155,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.shareLink.shouldShow(context), isTrue);
@@ -169,7 +173,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.shareLink.shouldShow(context), isFalse);
@@ -186,7 +191,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.shareLink.shouldShow(context), isFalse);
@@ -205,7 +211,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.archive.shouldShow(context), isTrue);
@@ -222,7 +229,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.archive.shouldShow(context), isFalse);
@@ -239,7 +247,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.archive.shouldShow(context), isFalse);
@@ -256,7 +265,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.archive.shouldShow(context), isFalse);
@@ -273,7 +283,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.archive.shouldShow(context), isFalse);
@@ -292,7 +303,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.unarchive.shouldShow(context), isTrue);
@@ -309,7 +321,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.unarchive.shouldShow(context), isFalse);
@@ -326,7 +339,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.unarchive.shouldShow(context), isFalse);
@@ -345,7 +359,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.download.shouldShow(context), isTrue);
@@ -362,7 +377,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.download.shouldShow(context), isFalse);
@@ -379,7 +395,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.download.shouldShow(context), isFalse);
@@ -398,7 +415,8 @@ void main() {
isStacked: false,
currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.similarPhotos.shouldShow(context), isTrue);
@@ -415,7 +433,8 @@ void main() {
currentAlbum: null,
isStacked: false,
advancedTroubleshooting: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.similarPhotos.shouldShow(context), isFalse);
@@ -434,7 +453,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.trash.shouldShow(context), isTrue);
@@ -451,7 +471,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.trash.shouldShow(context), isFalse);
@@ -470,7 +491,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.deletePermanent.shouldShow(context), isTrue);
@@ -487,7 +509,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.deletePermanent.shouldShow(context), isFalse);
@@ -506,7 +529,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.delete.shouldShow(context), isTrue);
@@ -525,7 +549,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.moveToLockFolder.shouldShow(context), isTrue);
@@ -544,7 +569,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.deleteLocal.shouldShow(context), isTrue);
@@ -561,7 +587,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.deleteLocal.shouldShow(context), isFalse);
@@ -577,7 +604,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.deleteLocal.shouldShow(context), isTrue);
@@ -596,7 +624,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.upload.shouldShow(context), isTrue);
@@ -615,7 +644,8 @@ void main() {
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.removeFromAlbum.shouldShow(context), isTrue);
@@ -631,7 +661,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.removeFromAlbum.shouldShow(context), isFalse);
@@ -829,7 +860,8 @@ void main() {
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.likeActivity.shouldShow(context), isTrue);
@@ -846,7 +878,8 @@ void main() {
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.likeActivity.shouldShow(context), isFalse);
@@ -863,7 +896,8 @@ void main() {
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.likeActivity.shouldShow(context), isFalse);
@@ -879,7 +913,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.likeActivity.shouldShow(context), isFalse);
@@ -897,7 +932,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: true,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.advancedInfo.shouldShow(context), isTrue);
@@ -913,7 +949,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.advancedInfo.shouldShow(context), isFalse);
@@ -933,6 +970,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: true,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
@@ -950,6 +988,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
@@ -967,6 +1006,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
@@ -989,6 +1029,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
});
@@ -1008,7 +1049,8 @@ void main() {
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>());
@@ -1022,7 +1064,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>());
@@ -1052,7 +1095,8 @@ void main() {
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: true,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>());
@@ -1076,6 +1120,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
@@ -1097,6 +1142,7 @@ void main() {
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
@@ -1116,6 +1162,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
@@ -1136,6 +1183,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
@@ -1150,6 +1198,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);

88
pnpm-lock.yaml generated
View File

@@ -570,8 +570,8 @@ importers:
specifier: ^2.0.0
version: 2.0.9
uuid:
specifier: ^11.1.0
version: 11.1.0
specifier: ^14.0.0
version: 14.0.0
validator:
specifier: ^13.12.0
version: 13.15.35
@@ -584,7 +584,7 @@ importers:
version: 10.0.1(eslint@10.2.1(jiti@2.6.1))
'@nestjs/cli':
specifier: ^11.0.2
version: 11.0.21(@swc/core@1.15.26(@swc/helpers@0.5.17))(@types/node@24.12.2)(esbuild@0.28.0)(prettier@3.8.3)
version: 11.0.21(@swc/core@1.15.26(@swc/helpers@0.5.21))(@types/node@24.12.2)(esbuild@0.28.0)(prettier@3.8.3)
'@nestjs/schematics':
specifier: ^11.0.0
version: 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@6.0.3)
@@ -593,7 +593,7 @@ importers:
version: 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(@nestjs/platform-express@11.1.19)
'@swc/core':
specifier: ^1.4.14
version: 1.15.26(@swc/helpers@0.5.17)
version: 1.15.26(@swc/helpers@0.5.21)
'@types/archiver':
specifier: ^7.0.0
version: 7.0.0
@@ -713,7 +713,7 @@ importers:
version: 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3)
unplugin-swc:
specifier: ^1.4.5
version: 1.5.9(@swc/core@1.15.26(@swc/helpers@0.5.17))(rollup@4.55.1)
version: 1.5.9(@swc/core@1.15.26(@swc/helpers@0.5.21))(rollup@4.55.1)
vite-tsconfig-paths:
specifier: ^6.0.0
version: 6.1.1(typescript@6.0.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
@@ -734,7 +734,7 @@ importers:
version: link:../open-api/typescript-sdk
'@immich/ui':
specifier: ^0.76.0
version: 0.76.0(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)
version: 0.76.2(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)
'@mapbox/mapbox-gl-rtl-text':
specifier: 0.4.0
version: 0.4.0
@@ -3024,8 +3024,8 @@ packages:
peerDependencies:
svelte: ^5.0.0
'@immich/ui@0.76.0':
resolution: {integrity: sha512-ghxfbC47UPMwQJ65maOUYdduQ/G/zo87Oc2ZUKe6o8KgoHsWxLVjQUw44T3dZdFOhvyS8SsIlkGLuagVcrM9Bg==}
'@immich/ui@0.76.2':
resolution: {integrity: sha512-D5oqBMyGg8x7YcrmWLgYO1z6d5BU454jejoDJqkW/oJGHMXCSSyY+l/skmVR+fLd1Pttf28gJE9TVG1xXqJ0rA==}
peerDependencies:
svelte: ^5.0.0
@@ -3172,8 +3172,8 @@ packages:
'@types/node':
optional: true
'@internationalized/date@3.12.0':
resolution: {integrity: sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ==}
'@internationalized/date@3.12.1':
resolution: {integrity: sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==}
'@ioredis/commands@1.5.1':
resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==}
@@ -4782,8 +4782,8 @@ packages:
'@swc/counter@0.1.3':
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
'@swc/helpers@0.5.17':
resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==}
'@swc/helpers@0.5.21':
resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==}
'@swc/types@0.1.26':
resolution: {integrity: sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==}
@@ -6002,8 +6002,8 @@ packages:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
bits-ui@2.16.3:
resolution: {integrity: sha512-5hJ5dEhf5yPzkRFcxzgQHScGodeo0gK0MUUXrdLlRHWaBOBGZiacWLG96j/wwFatKwZvouw7q+sn14i0fx3RIg==}
bits-ui@2.18.0:
resolution: {integrity: sha512-GLOBZRVy3hxNHIQ2MpD/+5aK9KcBFZRhUJtZ1UDABXdlVR4K6zFpgt4T+Rwuhf2sQzlc6yK1q/DprHPjwT4Pjw==}
engines: {node: '>=20'}
peerDependencies:
'@internationalized/date': ^3.8.1
@@ -8996,8 +8996,8 @@ packages:
engines: {node: '>= 20'}
hasBin: true
marked@17.0.5:
resolution: {integrity: sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg==}
marked@17.0.6:
resolution: {integrity: sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==}
engines: {node: '>= 20'}
hasBin: true
@@ -12110,6 +12110,10 @@ packages:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
uuid@14.0.0:
resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==}
hasBin: true
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
@@ -15349,16 +15353,16 @@ snapshots:
'@immich/svelte-markdown-preprocess@0.4.1(svelte@5.55.2)':
dependencies:
front-matter: 4.0.2
marked: 17.0.5
marked: 17.0.6
node-emoji: 2.2.0
svelte: 5.55.2
'@immich/ui@0.76.0(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)':
'@immich/ui@0.76.2(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)':
dependencies:
'@immich/svelte-markdown-preprocess': 0.4.1(svelte@5.55.2)
'@internationalized/date': 3.12.0
'@internationalized/date': 3.12.1
'@mdi/js': 7.4.47
bits-ui: 2.16.3(@internationalized/date@3.12.0)(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)
bits-ui: 2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)
luxon: 3.7.2
simple-icons: 16.16.0
svelte: 5.55.2
@@ -15509,9 +15513,9 @@ snapshots:
optionalDependencies:
'@types/node': 24.12.2
'@internationalized/date@3.12.0':
'@internationalized/date@3.12.1':
dependencies:
'@swc/helpers': 0.5.17
'@swc/helpers': 0.5.21
'@ioredis/commands@1.5.1': {}
@@ -15931,7 +15935,7 @@ snapshots:
bullmq: 5.74.1
tslib: 2.8.1
'@nestjs/cli@11.0.21(@swc/core@1.15.26(@swc/helpers@0.5.17))(@types/node@24.12.2)(esbuild@0.28.0)(prettier@3.8.3)':
'@nestjs/cli@11.0.21(@swc/core@1.15.26(@swc/helpers@0.5.21))(@types/node@24.12.2)(esbuild@0.28.0)(prettier@3.8.3)':
dependencies:
'@angular-devkit/core': 19.2.24(chokidar@4.0.3)
'@angular-devkit/schematics': 19.2.24(chokidar@4.0.3)
@@ -15942,17 +15946,17 @@ snapshots:
chokidar: 4.0.3
cli-table3: 0.6.5
commander: 4.1.1
fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.26(@swc/helpers@0.5.17))(esbuild@0.28.0))
fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.26(@swc/helpers@0.5.21))(esbuild@0.28.0))
glob: 13.0.6
node-emoji: 1.11.0
ora: 5.4.1
tsconfig-paths: 4.2.0
tsconfig-paths-webpack-plugin: 4.2.0
typescript: 5.9.3
webpack: 5.106.0(@swc/core@1.15.26(@swc/helpers@0.5.17))(esbuild@0.28.0)
webpack: 5.106.0(@swc/core@1.15.26(@swc/helpers@0.5.21))(esbuild@0.28.0)
webpack-node-externals: 3.0.0
optionalDependencies:
'@swc/core': 1.15.26(@swc/helpers@0.5.17)
'@swc/core': 1.15.26(@swc/helpers@0.5.21)
transitivePeerDependencies:
- '@types/node'
- esbuild
@@ -17073,7 +17077,7 @@ snapshots:
'@swc/core-win32-x64-msvc@1.15.26':
optional: true
'@swc/core@1.15.26(@swc/helpers@0.5.17)':
'@swc/core@1.15.26(@swc/helpers@0.5.21)':
dependencies:
'@swc/counter': 0.1.3
'@swc/types': 0.1.26
@@ -17090,11 +17094,11 @@ snapshots:
'@swc/core-win32-arm64-msvc': 1.15.26
'@swc/core-win32-ia32-msvc': 1.15.26
'@swc/core-win32-x64-msvc': 1.15.26
'@swc/helpers': 0.5.17
'@swc/helpers': 0.5.21
'@swc/counter@0.1.3': {}
'@swc/helpers@0.5.17':
'@swc/helpers@0.5.21':
dependencies:
tslib: 2.8.1
@@ -18493,11 +18497,11 @@ snapshots:
binary-extensions@2.3.0: {}
bits-ui@2.16.3(@internationalized/date@3.12.0)(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2):
bits-ui@2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2):
dependencies:
'@floating-ui/core': 1.7.5
'@floating-ui/dom': 1.7.6
'@internationalized/date': 3.12.0
'@internationalized/date': 3.12.1
esm-env: 1.2.2
runed: 0.35.1(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)
svelte: 5.55.2
@@ -20502,7 +20506,7 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.26(@swc/helpers@0.5.17))(esbuild@0.28.0)):
fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.26(@swc/helpers@0.5.21))(esbuild@0.28.0)):
dependencies:
'@babel/code-frame': 7.29.0
chalk: 4.1.2
@@ -20517,7 +20521,7 @@ snapshots:
semver: 7.7.4
tapable: 2.3.3
typescript: 5.9.3
webpack: 5.106.0(@swc/core@1.15.26(@swc/helpers@0.5.17))(esbuild@0.28.0)
webpack: 5.106.0(@swc/core@1.15.26(@swc/helpers@0.5.21))(esbuild@0.28.0)
form-data-encoder@2.1.4: {}
@@ -21928,7 +21932,7 @@ snapshots:
marked@16.4.2: {}
marked@17.0.5: {}
marked@17.0.6: {}
math-intrinsics@1.1.0: {}
@@ -25300,15 +25304,15 @@ snapshots:
- bare-abort-controller
- react-native-b4a
terser-webpack-plugin@5.4.0(@swc/core@1.15.26(@swc/helpers@0.5.17))(esbuild@0.28.0)(webpack@5.106.0(@swc/core@1.15.26(@swc/helpers@0.5.17))(esbuild@0.28.0)):
terser-webpack-plugin@5.4.0(@swc/core@1.15.26(@swc/helpers@0.5.21))(esbuild@0.28.0)(webpack@5.106.0(@swc/core@1.15.26(@swc/helpers@0.5.21))(esbuild@0.28.0)):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
schema-utils: 4.3.3
terser: 5.46.1
webpack: 5.106.0(@swc/core@1.15.26(@swc/helpers@0.5.17))(esbuild@0.28.0)
webpack: 5.106.0(@swc/core@1.15.26(@swc/helpers@0.5.21))(esbuild@0.28.0)
optionalDependencies:
'@swc/core': 1.15.26(@swc/helpers@0.5.17)
'@swc/core': 1.15.26(@swc/helpers@0.5.21)
esbuild: 0.28.0
terser-webpack-plugin@5.4.0(webpack@5.106.2):
@@ -25692,10 +25696,10 @@ snapshots:
unpipe@1.0.0: {}
unplugin-swc@1.5.9(@swc/core@1.15.26(@swc/helpers@0.5.17))(rollup@4.55.1):
unplugin-swc@1.5.9(@swc/core@1.15.26(@swc/helpers@0.5.21))(rollup@4.55.1):
dependencies:
'@rollup/pluginutils': 5.3.0(rollup@4.55.1)
'@swc/core': 1.15.26(@swc/helpers@0.5.17)
'@swc/core': 1.15.26(@swc/helpers@0.5.21)
load-tsconfig: 0.2.5
unplugin: 2.3.11
transitivePeerDependencies:
@@ -25779,6 +25783,8 @@ snapshots:
uuid@11.1.0: {}
uuid@14.0.0: {}
uuid@8.3.2: {}
validator@13.15.35: {}
@@ -26191,7 +26197,7 @@ snapshots:
webpack-virtual-modules@0.6.2: {}
webpack@5.106.0(@swc/core@1.15.26(@swc/helpers@0.5.17))(esbuild@0.28.0):
webpack@5.106.0(@swc/core@1.15.26(@swc/helpers@0.5.21))(esbuild@0.28.0):
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.8
@@ -26215,7 +26221,7 @@ snapshots:
neo-async: 2.6.2
schema-utils: 4.3.3
tapable: 2.3.3
terser-webpack-plugin: 5.4.0(@swc/core@1.15.26(@swc/helpers@0.5.17))(esbuild@0.28.0)(webpack@5.106.0(@swc/core@1.15.26(@swc/helpers@0.5.17))(esbuild@0.28.0))
terser-webpack-plugin: 5.4.0(@swc/core@1.15.26(@swc/helpers@0.5.21))(esbuild@0.28.0)(webpack@5.106.0(@swc/core@1.15.26(@swc/helpers@0.5.21))(esbuild@0.28.0))
watchpack: 2.5.1
webpack-sources: 3.3.4
transitivePeerDependencies:

View File

@@ -114,7 +114,7 @@
"thumbhash": "^0.1.1",
"transformation-matrix": "^3.1.0",
"ua-parser-js": "^2.0.0",
"uuid": "^11.1.0",
"uuid": "^14.0.0",
"validator": "^13.12.0",
"zod": "^4.3.6"
},

View File

@@ -196,6 +196,7 @@ describe(AlbumService.name, () => {
expect(mocks.user.get).toHaveBeenCalledWith(albumUser.userId, {});
expect(mocks.user.getMetadata).toHaveBeenCalledWith(owner.id);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([assetId]), false);
expect(mocks.event.emit).toHaveBeenCalledTimes(1);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', {
id: album.id,
userId: albumUser.userId,

View File

@@ -114,7 +114,6 @@ export class AlbumService extends BaseService {
throw new BadRequestException('Cannot share album with owner');
}
}
albumUsers.unshift({ userId: auth.user.id, role: AlbumUserRole.Owner });
const allowedAssetIdsSet = await this.checkAccess({
auth,
@@ -133,7 +132,7 @@ export class AlbumService extends BaseService {
order: getPreferences(userMetadata).albums.defaultAssetOrder,
},
assetIds,
albumUsers,
[{ userId: auth.user.id, role: AlbumUserRole.Owner }, ...albumUsers],
auth.user.id,
);

View File

@@ -10,7 +10,6 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { locale } from '$lib/stores/preferences.store';
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { delay, getDimensions } from '$lib/utils/asset-utils';
@@ -56,7 +55,6 @@
let isOwner = $derived(authManager.authenticated && authManager.user.id === asset.ownerId);
let people = $derived(asset.people || []);
let unassignedFaces = $derived(asset.unassignedFaces || []);
let showingHiddenPeople = $state(false);
let latlng = $derived(
(() => {
const lat = asset.exifInfo?.latitude;
@@ -173,12 +171,12 @@
{#if people.some((person) => person.isHidden)}
<IconButton
aria-label={$t('show_hidden_people')}
icon={showingHiddenPeople ? mdiEyeOff : mdiEye}
icon={assetViewerManager.isShowingHiddenPeople ? mdiEyeOff : mdiEye}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => (showingHiddenPeople = !showingHiddenPeople)}
onclick={() => assetViewerManager.toggleHiddenPeople()}
/>
{/if}
<IconButton
@@ -207,15 +205,17 @@
<div class="mt-2 flex flex-wrap gap-2">
{#each people as person, index (person.id)}
{#if showingHiddenPeople || !person.isHidden}
{@const isHighlighted = people[index].faces.some((f) => $boundingBoxesArray.some((b) => b.id === f.id))}
{#if assetViewerManager.isShowingHiddenPeople || !person.isHidden}
{@const isHighlighted = people[index].faces.some((f) =>
assetViewerManager.highlightedFaces.some((b) => b.id === f.id),
)}
<a
class="group w-22 outline-none"
href={Route.viewPerson(person, { previousRoute })}
onfocus={() => ($boundingBoxesArray = people[index].faces)}
onblur={() => ($boundingBoxesArray = [])}
onmouseover={() => ($boundingBoxesArray = people[index].faces)}
onmouseleave={() => ($boundingBoxesArray = [])}
onfocus={() => assetViewerManager.setHighlightedFaces(people[index].faces)}
onblur={() => assetViewerManager.clearHighlightedFaces()}
onpointerenter={() => assetViewerManager.setHighlightedFaces(people[index].faces)}
onpointerleave={() => assetViewerManager.clearHighlightedFaces()}
>
<div class="relative">
<ImageThumbnail

View File

@@ -1,9 +1,8 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { assetViewerManager, type Faces } from '$lib/managers/asset-viewer-manager.svelte';
import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { calculateBoundingBoxMatrix, getOcrBoundingBoxes, type Point } from '$lib/utils/ocr-utils';
import {
@@ -55,14 +54,9 @@
let viewer: Viewer;
let animationInProgress: { cancel: () => void } | undefined;
let previousFaces: Faces[] = [];
const boundingBoxesUnsubscribe = boundingBoxesArray.subscribe((faces: Faces[]) => {
// Debounce; don't do anything when the data didn't actually change.
if (faces === previousFaces) {
return;
}
previousFaces = faces;
$effect(() => {
const faces: Faces[] = assetViewerManager.highlightedFaces;
if (animationInProgress) {
animationInProgress.cancel();
@@ -105,7 +99,7 @@
textureX: x,
textureY: y,
zoom: Math.min(viewer.getZoomLevel(), 75),
speed: 500, // duration in ms
speed: 500,
});
}
});
@@ -247,7 +241,8 @@
if (viewer) {
viewer.destroy();
}
boundingBoxesUnsubscribe();
assetViewerManager.clearHighlightedFaces();
assetViewerManager.hideHiddenPeople();
assetViewerManager.zoom = 1;
});
</script>

View File

@@ -6,10 +6,9 @@
import Thumbhash from '$lib/components/Thumbhash.svelte';
import OcrBoundingBox from '$lib/components/asset-viewer/OcrBoundingBox.svelte';
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { assetViewerManager, type Faces } from '$lib/managers/asset-viewer-manager.svelte';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
@@ -50,12 +49,13 @@
untrack(() => {
assetViewerManager.resetZoomState();
visibleImageReady = false;
$boundingBoxesArray = [];
assetViewerManager.clearHighlightedFaces();
});
});
onDestroy(() => {
$boundingBoxesArray = [];
assetViewerManager.clearHighlightedFaces();
assetViewerManager.hideHiddenPeople();
});
let containerWidth = $state(0);
@@ -74,15 +74,13 @@
return scaleToFit(getNaturalSize(assetViewerManager.imgRef), { width: containerWidth, height: containerHeight });
});
const highlightedBoxes = $derived(getBoundingBox($boundingBoxesArray, overlaySize));
const highlightedBoxes = $derived(getBoundingBox(assetViewerManager.highlightedFaces, overlaySize));
const isHighlighting = $derived(highlightedBoxes.length > 0);
let visibleBoxes = $state<BoundingBox[]>([]);
let visibleBoundingBoxes = $state<Faces[]>([]);
$effect(() => {
if (isHighlighting) {
visibleBoxes = highlightedBoxes;
visibleBoundingBoxes = $boundingBoxesArray;
}
});
@@ -160,6 +158,9 @@
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const map = new Map<Faces, string>();
for (const person of asset.people ?? []) {
if (person.isHidden && !assetViewerManager.isShowingHiddenPeople) {
continue;
}
for (const face of person.faces ?? []) {
map.set(face, person.name);
}
@@ -169,35 +170,31 @@
const faces = $derived(Array.from(faceToNameMap.keys()));
const handleImageMouseMove = (event: MouseEvent) => {
$boundingBoxesArray = [];
if (!assetViewerManager.imgRef || !element || assetViewerManager.isFaceEditMode || ocrManager.showOverlay) {
return;
const boundingBoxes = $derived.by(() => {
if (assetViewerManager.isFaceEditMode || ocrManager.showOverlay) {
return [];
}
const natural = getNaturalSize(assetViewerManager.imgRef);
const scaled = scaleToFit(natural, container);
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
const knownBoxes = getBoundingBox(faces, overlaySize);
const result = knownBoxes.map((box, index) => ({
...box,
face: faces[index],
name: faceToNameMap.get(faces[index]),
}));
const contentOffsetX = (container.width - scaled.width) / 2;
const contentOffsetY = (container.height - scaled.height) / 2;
const containerRect = element.getBoundingClientRect();
const mouseX = (event.clientX - containerRect.left - contentOffsetX * currentZoom - currentPositionX) / currentZoom;
const mouseY = (event.clientY - containerRect.top - contentOffsetY * currentZoom - currentPositionY) / currentZoom;
const faceBoxes = getBoundingBox(faces, overlaySize);
for (const [index, box] of faceBoxes.entries()) {
if (mouseX >= box.left && mouseX <= box.left + box.width && mouseY >= box.top && mouseY <= box.top + box.height) {
$boundingBoxesArray.push(faces[index]);
}
if (assetViewerManager.highlightedFaces.length === 0) {
return result;
}
};
const handleImageMouseLeave = () => {
$boundingBoxesArray = [];
};
const knownIds = new Set(faces.map((f) => f.id));
const unassignedFaces = assetViewerManager.highlightedFaces.filter((f) => !knownIds.has(f.id));
const unassignedBoxes = getBoundingBox(unassignedFaces, overlaySize);
for (let i = 0; i < unassignedBoxes.length; i++) {
result.push({ ...unassignedBoxes[i], face: unassignedFaces[i], name: undefined });
}
return result;
});
</script>
<AssetViewerEvents {onCopy} {onZoom} {onFaceEditModeChange} />
@@ -218,8 +215,6 @@
bind:clientHeight={containerHeight}
role="presentation"
ondblclick={onZoom}
onmousemove={handleImageMouseMove}
onmouseleave={handleImageMouseLeave}
use:zoomImageAction={{ zoomTarget: adaptiveImage }}
{...useSwipe((event) => onSwipe?.(event))}
>
@@ -261,22 +256,27 @@
</defs>
<rect width="100%" height="100%" fill="rgba(0,0,0,0.4)" mask="url(#face-dim-mask)" />
</svg>
{#each visibleBoxes as boundingbox, index (boundingbox.id)}
<div
class="absolute border-solid border-white border-3 rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
></div>
{#if faceToNameMap.get(visibleBoundingBoxes[index])}
</div>
{#each boundingBoxes as boundingbox (boundingbox.id)}
{@const isActive = assetViewerManager.highlightedFaces.some((f) => f.id === boundingbox.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute pointer-events-auto rounded-lg {isActive && 'border-solid border-white border-3'}"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
onpointerenter={() => assetViewerManager.setHighlightedFaces([boundingbox.face])}
onpointerleave={() => assetViewerManager.clearHighlightedFaces()}
>
{#if isActive && boundingbox.name}
<div
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap pointer-events-none shadow-lg"
style="top: {boundingbox.top + boundingbox.height + 4}px; left: {boundingbox.left +
boundingbox.width}px; transform: translateX(-100%);"
aria-hidden="true"
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap shadow-lg"
style="top: {boundingbox.height + 4}px; right: 0;"
>
{faceToNameMap.get(visibleBoundingBoxes[index])}
{boundingbox.name}
</div>
{/if}
{/each}
</div>
</div>
{/each}
{#each ocrBoxes as ocrBox (ocrBox.id)}
<OcrBoundingBox {ocrBox} />

View File

@@ -4,7 +4,6 @@
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { zoomImageToBase64 } from '$lib/utils/people-utils';
@@ -239,15 +238,15 @@
{:else}
{#each peopleWithFaces as face, index (face.id)}
{@const personName = face.person ? face.person?.name : $t('face_unassigned')}
{@const isHighlighted = $boundingBoxesArray.some((b) => b.id === face.id)}
{@const isHighlighted = assetViewerManager.highlightedFaces.some((b) => b.id === face.id)}
<div class="relative h-29 w-24">
<div
role="button"
tabindex={index}
class="absolute start-0 top-0 h-22.5 w-22.5 cursor-default"
onfocus={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
onmouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
onmouseleave={() => ($boundingBoxesArray = [])}
onfocus={() => assetViewerManager.setHighlightedFaces([peopleWithFaces[index]])}
onpointerenter={() => assetViewerManager.setHighlightedFaces([peopleWithFaces[index]])}
onpointerleave={() => assetViewerManager.clearHighlightedFaces()}
>
<div class="relative">
{#if selectedPersonToCreate[face.id]}

View File

@@ -8,6 +8,16 @@ import { BaseEventManager } from '$lib/utils/base-event-manager.svelte';
import type { AssetGridRouteSearchParams } from '$lib/utils/navigation';
import { PersistedLocalStorage } from '$lib/utils/persisted';
export interface Faces {
id: string;
imageHeight: number;
imageWidth: number;
boundingBoxX1: number;
boundingBoxX2: number;
boundingBoxY1: number;
boundingBoxY2: number;
}
const isShowDetailPanel = new PersistedLocalStorage<boolean>('asset-viewer-state', false);
const isShowAssetPath = new PersistedLocalStorage<boolean>('asset-viewer-show-path', false);
@@ -48,6 +58,8 @@ class AssetViewerManager extends BaseEventManager<Events> {
#isEditFacesPanelOpen = $state(false);
#viewingAssetStoreState = $state<AssetResponseDto>();
#viewState = $state<boolean>(false);
#highlightedFaces = $state<Faces[]>([]);
#showingHiddenPeople = $state(false);
gridScrollTarget = $state<AssetGridRouteSearchParams | null | undefined>();
get asset() {
@@ -209,6 +221,31 @@ class AssetViewerManager extends BaseEventManager<Events> {
this.closeFaceEditMode();
this.closeEditFacesPanel();
}
get highlightedFaces() {
return this.#highlightedFaces;
}
setHighlightedFaces(faces: Faces[]) {
this.#highlightedFaces = faces;
}
clearHighlightedFaces() {
this.#highlightedFaces = [];
}
get isShowingHiddenPeople() {
return this.#showingHiddenPeople;
}
toggleHiddenPeople() {
this.#showingHiddenPeople = !this.#showingHiddenPeople;
}
hideHiddenPeople() {
this.#showingHiddenPeople = false;
}
setAsset(asset: AssetResponseDto) {
this.#viewingAssetStoreState = asset;
this.#viewState = true;

View File

@@ -1,13 +0,0 @@
import { writable } from 'svelte/store';
export interface Faces {
id: string;
imageHeight: number;
imageWidth: number;
boundingBoxX1: number;
boundingBoxX2: number;
boundingBoxY1: number;
boundingBoxY2: number;
}
export const boundingBoxesArray = writable<Faces[]>([]);

View File

@@ -1,4 +1,4 @@
import type { Faces } from '$lib/stores/people.store';
import type { Faces } from '$lib/managers/asset-viewer-manager.svelte';
import type { Size } from '$lib/utils/container-utils';
import { getBoundingBox } from '$lib/utils/people-utils';

View File

@@ -1,5 +1,5 @@
import { AssetTypeEnum, type AssetFaceResponseDto } from '@immich/sdk';
import type { Faces } from '$lib/stores/people.store';
import type { Faces } from '$lib/managers/asset-viewer-manager.svelte';
import { getAssetMediaUrl } from '$lib/utils';
import { mapNormalizedRectToContent, type Rect, type Size } from '$lib/utils/container-utils';