Compare commits

...

55 Commits

Author SHA1 Message Date
shenlong 216d0ba365 fix: notify timeline updates after sync (#28846)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-04 18:17:04 -05:00
Timon 28e42f7e29 refactor(mobile): use Optional only on API boundary (#28845) 2026-06-05 04:29:13 +05:30
Yaros 733373c0ca feat(mobile): ocr support (#26523) 2026-06-05 04:29:03 +05:30
shenlong 5617d6ca7c ci: ios builds (#28847)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-05 03:50:39 +05:30
Brandon Wees 875dd2dead fix: check continue origins with URL constructor (#28835)
* fix: check origins with URL constructor

* fix: fallback

* chore: tests
2026-06-04 16:20:05 -04:00
Jason Rasmussen 9043bc8435 fix: error handling (#28843) 2026-06-04 16:19:16 -04:00
Abhijeet Sanjiv Bonde b3d49045de feat: user upload heatmap (#28593)
* Feat - Heatmap

* Implemented Comments to prettify and code cleanup

* fixing code to pass cases.

* fixing errors for OpenAPI Clients

* Improving the code.

* Fix code

* Rerun generated client check

* Rerun generated client

* feat: command for user pages (#28554)

* fix(web): timeline stuttering with many assets in 1 day (#28509)

* fix(web): timeline stuttering with many assets in 1 day

* cache isInOrNearViewport per day

* skip inOrNearViewport check on first run

* chore(ml): allow insightface 1.x (#28595)

* chore(ml): allow insightface 1.x

The new insightface 1.0 release appears to have no breaking code changes nor relevant license changes ([before](https://github.com/deepinsight/insightface/blob/2a78baec428354883e0cda39c54b555a5ed8358a/README.md), [after](https://github.com/deepinsight/insightface/blob/70f3269ea628d0658c5723976944c9de414e96f8/README.md), c.f. https://github.com/immich-app/immich/blob/fd7ddfef54cdf2b6256c4fc08bc5ff3f86176775/machine-learning/README.md), and it works on my machine.

* Update uv.lock

* please excuse my incompetence

* Triggering the actions.

* bad merge

* Fix code

* Code clear

* Resolve conflict

* Resolve conflict

* Resolve conflict

* Resolve errors

* Resolve errors

* Resolve errors more

* chore: clean up

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
Co-authored-by: Ben Beckford <ben@benjaminbeckford.com>
Co-authored-by: Aaron Liu <aaronliu0130@gmail.com>
Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-06-04 15:36:09 -04:00
shenlong 58528cad08 refactor: replace drift_flutter with drift_sqlite_async (#28440)
replace drift_flutter with drift_sqlite_async

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-05 00:01:50 +05:30
Mees Frensel 99281de6ab refactor!: disallow star rating < 1 (#27896)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
Co-authored-by: timonrieger <mail@timonrieger.de>
2026-06-04 17:06:28 +00:00
Daniel Dietzler 6268d23d12 fix: restore video play/plause shortcut (#28837) 2026-06-04 18:51:16 +02:00
Daniel Dietzler d7999ce1d1 feat: workflows drag and drop enhancements (#28764) 2026-06-04 10:46:51 -05:00
shenlong 6b0fd89cd2 refactor: partner-page (#28783)
* refactor: partner-page

* cleanup

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-04 09:05:45 -04:00
Daniel Dietzler 4b0adb7a1e fix: stack arrow navigation when not directly navigating to an asset (#28828) 2026-06-04 14:49:12 +02:00
shenlong de70d19d20 feat: show notification and battery optimization warning (#26610)
* feat: show notification and battery optimization warning

* cleanup

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-04 12:24:39 +00:00
bo0tzz 7155bb1e80 chore: fix up docs placeholders (#28814) 2026-06-04 08:19:40 -04:00
Alex fa08e72d30 chore: scope flutter install from mise (#28820)
* chore: scope flutter install from mise

* ci: scope use-mise to mobile directory

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-04 17:24:38 +05:30
Timon e2de8c7c53 refactor(server)!: remove changeExpiryTime (#28816)
* fix(mobile): clear shared link password

* fix(mobile): clear shared link description

* fix(mobile): clear shared link expiry

* refactor(server)!: remove changeExpiryTime

* fix(mobile): clear shared link expiry

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-06-04 08:35:45 +00:00
Santo Shakil 429e181c8f fix(mobile): run iOS bg task phases in parallel (#28293)
onIosUpload runs sync local, sync remote, hash and handle backup
sequentially. on the bg refresh task path that's a 20s budget from
iOS, and sync + hash usually eat all of it before backup gets a turn
to enqueue any candidates.

these phases don't actually depend on each other. local + remote sync
touch different tables. hash works off whatever's already in drift.
handle backup reads candidates and just enqueues to URLSession bg.
anything one phase produces in this fire shows up to the others on
the next fire, and server-side dedup catches the rare race where
backup enqueues something sync remote was about to mark as already
uploaded.

so this runs all four concurrently via Future.wait, with hash getting
the full maxSeconds-1 budget instead of a fixed 5s. outer budget
timeout still caps everything before iOS expires.

second small change: getAssetsToHash orders by createdAt DESC instead
of id ASC to match getCandidates. when hash runs inside a refresh
fire it processes recent photos first.
2026-06-03 20:13:52 -05:00
winston 7f611d9031 test: fix tests when OpenVINO provider is available (#28802)
mocking `onnxruntime.get_available_providers()` to always use the CPU EP.
2026-06-03 20:52:08 -04:00
Timon e94e22f3f8 fix(server): respect timezone in iso date string encoding (#28810) 2026-06-03 19:00:10 -04:00
Timon 4a8c3b60be fix(mobile): clear album description sends null instead of empty string (#28817) 2026-06-03 18:22:19 -04:00
Timon 2190aa72a8 refactor(server): zod int validation (#28804) 2026-06-03 18:21:07 -04:00
Timon d21cb28526 fix(mobile): shared link edit sends explicit null instead of empty string (#28812)
* fix(mobile): clear shared link password

* fix(mobile): clear shared link description

* fix(mobile): clear shared link expiry
2026-06-03 18:19:35 -04:00
Timon 5c33eb3204 refactor(server)!: drop empty string to null conversion (#28808)
refactor(server): drop empty string to null conversion
2026-06-03 18:16:53 -04:00
Mert 137687bc0f fix(web): set src for progressive video player (#28813)
set src
2026-06-03 17:07:23 -04:00
Peter Ombodi 9d4a6614b1 feat(mobile): Android. Immich as a gallery / image viewer app (#26109)
* feat(mobile): handle Android ACTION_VIEW intent
- add ViewIntent Pigeon API and generated bindings
- implement Android ViewIntentPlugin + iOS no-op host
- route ExternalMediaViewer by ViewIntentAttachment
- buffer pending view intents and flush on user ready/resume

* feat(mobile): fallback to computed checksum for timeline match
- hash local asset on-demand when checksum missing
- search main timeline by localId or checksum before standalone viewer
- persist computed hash into local_asset_entity

* fix(mobile): proper handling is user authenticated

* feat(mobile): open ACTION_VIEW fallback in AssetViewer
drop ExternalMediaViewer route

* feat(mobile): add logger

* test(mobile): add unit tests for view intent pending/flush flow

* fix(mobile): fix format

* fix(mobile): remove redundant iOS code
update code related to LocalAsset model and asset viewer

* refactor(mobile): simplify view intent flow and support file-backed ACTION_VIEW assets
remove redundant view intent model/repository layer
handle transient ACTION_VIEW files in viewer/upload flow
clean up managed temp files for fallback assets

* refactor(mobile): extract MediaStore utils and resolve view intents via merged assets

* refactor(mobile): move deferred view intents into providers, split view-intent providers, and clean up ACTION_VIEW handling

* refactor(mobile): resolve merge conflicts
use NativeSyncApi for hash files instead method from removed BackgroundServicePlugin.kt

* style(mobile): format files

* style(mobile): format files #2

* refactor(mobile): lazily materialize view-intent files and clean up temp-file handling

* fix(mobile): flush pending view intents after login navigation

* refactor(mobile): split view intent handler by platform and trigger it from app events

* refactor(mobile): move view intent handling behind platform-specific factories

* refactor(mobile): simplify code

* fix(mobile): hand off deep-link viewer to main timeline after upload
Add MainTimelineHandoffCoordinator to switch the asset viewer to the main timeline once a view-intent asset is uploaded and becomes available, and guard viewer reload/navigation transitions to avoid race conditions and crashes.

* refactor(mobile): use remote asset ids for view intent handoff and simplify resolver

* refactor(mobile): resolve merge conflicts

* style(mobile): reformat code

* style(mobile): reformat code #2

* fix(mobile): stabilize Android view intent asset resolution and fallback viewer

* refactor(mobile): share AssetViewer pre-navigation state preparation

* fix(mobile): wait for main timeline before deferred view intent handoff

* refactor(mobile): decouple view intent asset resolver from providers

* fix(mobile): avoid double pop when canceling upload dialog

* fix(mobile): resolve view intent MIME type with fallbacks

* docs(mobile): clarify view intent fallback asset TODO

* fix(mobile): resolve merge conflicts

* cleanup

* lint

---------

Co-authored-by: Peter Ombodi <peter.ombodi@gmail.com>
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2026-06-03 12:05:52 -05:00
Jason Rasmussen e4352a7817 fix: error log on aborted uploads (#28806) 2026-06-03 12:47:38 -04:00
shenlong 911dde39c9 ci: verify mobile backward compatibility (#28786)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-03 15:21:23 +00:00
moversity afa836181c fix(cli): prevent out-of-memory on file upload due to undici storing the request body (#28723)
fix(cli): add fetch param to prevent OOM of upload

Issue due to undici storing the entire request body in memory.
Related undici bug report: https://github.com/nodejs/undici/issues/4058

Fixes https://github.com/immich-app/immich/issues/28720

Signed-off-by: moversity <148445403+moversity@users.noreply.github.com>
2026-06-03 15:19:35 +00:00
Mert 963862b1b9 fix(mobile): proper background task cleanup (#28694)
* event-based cancellation

wire hash cancellation

await cleanup

remove forced kill

add regression tests

abort sync requests

fix cleanup ordering in teardown

exit isolate

test background sync

test sigabrt crash

cleanup

* abort local sync
2026-06-03 08:16:19 -04:00
Timon 96d521e149 feat(mobile): add three-state field serialization (#27231)
* bump to v7.22.0 and update patching

* gen client

* migrate mobile call sites
2026-06-03 08:13:17 -04:00
shenlong 1bb7517da0 chore: pump flutter to 3.44.1 (#28785)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-02 23:45:31 -05:00
shenlong 814c2e32e4 chore: patch minFaces and realtimeTranscoding (#28784)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-03 09:15:31 +05:30
immich-tofu[bot] 92841f311f Added Code of conduct 2026-06-02 21:57:50 +00:00
immich-tofu[bot] 9d2e576630 chore: modify .github/FUNDING.yml 2026-06-02 21:57:47 +00:00
immich-tofu[bot] 936418a464 chore: use immich.app email for security reports (#10594)
chore: use  immich.app email for security reports
2026-06-02 21:57:45 +00:00
Daniel Dietzler 84c75d95c7 fix: migration order (#28779) 2026-06-02 21:33:13 +00:00
shenlong 9287fa08c6 fix!: unauthorized face creation (#28561)
* fix: unauthorized face creation

* review changes

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-02 22:44:11 +05:30
renovate[bot] 408e1180ca chore(deps): update machine-learning (#28239)
* chore(deps): update machine-learning

* fix typing

* fix deprecation log

* no control socket

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2026-06-02 16:44:50 +00:00
renovate[bot] 07f19d2caa chore(deps): update base-image to v202606021219 (#28771) 2026-06-02 18:31:52 +02:00
Tim Jones 368cb7a4ad feat: minimum face count per user (#27452)
* add user metadata table and use to filter persons in person.getAllForUser query

* update PersonRepository.getAllForUser query

* remove minFaces from PersonSearchOptions interface

* fix person.getAllForUser query

* update types and openapi specs

* add minFaces field to user settings page

* remove old arg from tests

* add e2e test to verify minimumFace user preference

* add i18n label and description for english

* update default min faces

* fetch minFaces ML default and use as per-user default in frontend

* update e2e tests

* fix bugs in people getAllForUser query

* update person getNumberOfPeople query to reflect correct number of people according to minFaces threshold

* updated mobile openapi specs?

* use subquery in coalesce instead of join

* remove out of scope query update
2026-06-02 18:05:55 +02:00
Timon 109e0a7ad0 fix(mobile): invisible ink splashes in asset sheet (#28756) 2026-06-02 10:37:20 -05:00
Timon 59750dad7d feat: places in context search (#28768) 2026-06-02 17:19:59 +02:00
okxint 13ecfc8876 fix(web): prevent partner assets from being selected in geolocation utility (#28737)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-06-02 15:05:15 +00:00
Min Idzelis 65d8b35f8b refactor(web): align gallery-viewer viewport naming and tunables (#28743) 2026-06-02 14:54:44 +02:00
renovate[bot] 942d3c648c chore(deps): lock file maintenance (npm) (#28729) 2026-06-02 14:51:55 +02:00
renovate[bot] 82db8be5ff chore(deps): update dependency testcontainers to v12 (#28763) 2026-06-02 12:05:42 +00:00
Min Idzelis 03554b24ad fix(web): skip thumbhash fade for offscreen thumbnails (#27335) 2026-06-02 13:42:33 +02:00
renovate[bot] c5fb67c004 chore(deps): update dependency prettier-plugin-svelte to v4 (#28762) 2026-06-02 13:38:57 +02:00
renovate[bot] 40983b46c8 chore(deps): update dependency @vitest/coverage-v8 to v4 (#28761) 2026-06-02 13:37:34 +02:00
renovate[bot] 5dcdbf04ea chore(deps): update base-image to v202605121138 (#28760) 2026-06-02 11:47:20 +02:00
renovate[bot] da8ed3eceb chore(deps): update docker.io/valkey/valkey:9 docker digest to 4963247 (#28622) 2026-06-02 08:09:27 +00:00
renovate[bot] 2afde23a5d chore(deps): update github-actions (#28750)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 00:39:19 -04:00
renovate[bot] d57a152040 chore(deps): update prom/prometheus docker digest to 69f5241 (#28757)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 00:37:42 -04:00
renovate[bot] 728e92ea33 chore(deps): update dependency @immich/ui to v0.79.3 (#28758)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 00:37:10 -04:00
449 changed files with 41621 additions and 5793 deletions
-1
View File
@@ -1 +0,0 @@
custom: ['https://buy.immich.app', 'https://immich.store']
+5 -3
View File
@@ -51,7 +51,7 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -79,7 +79,7 @@ jobs:
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -94,6 +94,7 @@ jobs:
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
working_directory: ./mobile
- name: Create the Keystore
if: ${{ !github.event.pull_request.head.repo.fork }}
@@ -201,7 +202,7 @@ jobs:
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -219,6 +220,7 @@ jobs:
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
working_directory: ./mobile
- name: Install Flutter dependencies
working-directory: ./mobile
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
actions: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+35 -1
View File
@@ -4,6 +4,7 @@ on:
pull_request:
paths:
- 'open-api/**'
- 'mobile/lib/utils/openapi_patching.dart'
- '.github/workflows/check-openapi.yml'
concurrency:
@@ -24,8 +25,41 @@ jobs:
persist-credentials: false
- name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@6147a58e5d1249a12f42fc864ab791d571a30015 # v0.0.47
uses: oasdiff/oasdiff-action/breaking@50e6a3413e5aa9c3ae4d8393c34745be44288b46 # v0.0.48
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json
fail-on: ERR
check-mobile-patches:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ github.token }}
working_directory: ./mobile
- name: Get packages
working-directory: ./mobile
run: flutter pub get
- name: Fetch base spec from main
run: |
curl -fsSL \
"https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json" \
-o /tmp/base-spec.json
- name: Check newly-required fields have a backward-compat patch
working-directory: ./mobile
env:
OPENAPI_BASE_SPEC: /tmp/base-spec.json
OPENAPI_REVISION_SPEC: ../open-api/immich-openapi-specs.json
run: flutter test test/openapi_patches_coverage.dart
+7 -7
View File
@@ -31,7 +31,7 @@ jobs:
working-directory: ./packages/cli
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -63,7 +63,7 @@ jobs:
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -75,13 +75,13 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
registry: ghcr.io
@@ -96,7 +96,7 @@ jobs:
- name: Generate docker image tags
id: metadata
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
flavor: |
latest=false
@@ -107,7 +107,7 @@ jobs:
type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }}
- name: Build and push image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
file: packages/cli/Dockerfile
platforms: linux/amd64,linux/arm64
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
needs: [get_body, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' }}
container:
image: ghcr.io/immich-app/mdq:main@sha256:0a8b8867773a0f8368061f47578603f438349f8f1f28b0e16105f481e5c794e0
image: ghcr.io/immich-app/mdq:main@sha256:e73f60195b39748c4876f23e3e6cd22a68a9754acec8aef1fd6979fd52cd2c9f
outputs:
checked: ${{ steps.get_checkbox.outputs.checked }}
steps:
+4 -4
View File
@@ -44,7 +44,7 @@ jobs:
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
category: '/language:${{matrix.language}}'
+3 -3
View File
@@ -23,7 +23,7 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -60,7 +60,7 @@ jobs:
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -90,7 +90,7 @@ jobs:
suffix: ['']
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -54,7 +54,7 @@ jobs:
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+2 -2
View File
@@ -20,7 +20,7 @@ jobs:
artifact: ${{ steps.get-artifact.outputs.result }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -126,7 +126,7 @@ jobs:
if: ${{ fromJson(needs.checks.outputs.artifact).found && fromJson(needs.checks.outputs.parameters).shouldDeploy }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
pull-requests: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
pull-requests: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
pull-requests: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+1 -1
View File
@@ -49,7 +49,7 @@ jobs:
permissions: {} # No job-level permissions are needed because it uses the app-token
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+2 -2
View File
@@ -14,7 +14,7 @@ jobs:
pull-requests: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -32,7 +32,7 @@ jobs:
pull-requests: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
packages: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+3 -2
View File
@@ -20,7 +20,7 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -49,7 +49,7 @@ jobs:
working-directory: ./mobile
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -64,6 +64,7 @@ jobs:
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
working_directory: ./mobile
- name: Install dependencies
run: flutter pub get
+18 -17
View File
@@ -17,7 +17,7 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -71,7 +71,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -102,7 +102,7 @@ jobs:
working-directory: ./packages/cli
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -133,7 +133,7 @@ jobs:
working-directory: ./packages/cli
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -177,7 +177,7 @@ jobs:
working-directory: ./web
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -215,7 +215,7 @@ jobs:
working-directory: ./web
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -243,7 +243,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -293,7 +293,7 @@ jobs:
working-directory: ./e2e
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -325,7 +325,7 @@ jobs:
working-directory: ./server
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -361,7 +361,7 @@ jobs:
runner: [ubuntu-latest, ubuntu-24.04-arm]
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -438,7 +438,7 @@ jobs:
runner: [ubuntu-latest, ubuntu-24.04-arm]
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -546,7 +546,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -560,6 +560,7 @@ jobs:
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
working_directory: ./mobile
- name: Install dependencies
run: flutter pub get
@@ -583,7 +584,7 @@ jobs:
working-directory: ./machine-learning
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -613,7 +614,7 @@ jobs:
working-directory: ./.github
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -643,7 +644,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -664,7 +665,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -722,7 +723,7 @@ jobs:
- 5432:5432
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -47,7 +47,7 @@ jobs:
if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
-134
View File
@@ -1,134 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation
in our community a harassment-free experience for everyone, regardless
of age, body size, visible or invisible disability, ethnicity, sex
characteristics, gender identity and expression, level of experience,
education, socio-economic status, nationality, personal appearance,
race, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open,
welcoming, diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for
our community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our
mistakes, and learning from the experience
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
- Trolling, insulting or derogatory comments, and personal or
political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in
a professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our
standards of acceptable behavior and will take appropriate and fair
corrective action in response to any behavior that they deem
inappropriate, threatening, offensive, or harmful.
Community leaders have the right and responsibility to remove, edit,
or reject comments, commits, code, wiki edits, issues, and other
contributions that are not aligned to this Code of Conduct, and will
communicate reasons for moderation decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also
applies when an individual is officially representing the community in
public spaces. Examples of representing our community include using an
official e-mail address, posting via an official social media account,
or acting as an appointed representative at an online or offline
event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior
may be reported to the community leaders responsible for enforcement
at our Discord channel. All complaints
will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and
security of the reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in
determining the consequences for any action they deem in violation of
this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior
deemed unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders,
providing clarity around the nature of the violation and an
explanation of why the behavior was inappropriate. A public apology
may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued
behavior. No interaction with the people involved, including
unsolicited interaction with those enforcing the Code of Conduct, for
a specified period of time. This includes avoiding interactions in
community spaces as well as external channels like social
media. Violating these terms may lead to a temporary or permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards,
including sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or
public communication with the community for a specified period of
time. No public or private interaction with the people involved,
including unsolicited interaction with those enforcing the Code of
Conduct, is allowed during this period. Violating these terms may lead
to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of
community standards, including sustained inappropriate behavior,
harassment of an individual, or aggression toward or disparagement of
classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction
within the community.
## Attribution
This Code of Conduct is adapted from the [Contributor
Covenant][homepage], version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of
conduct enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the
FAQ at https://www.contributor-covenant.org/faq. Translations are
available at https://www.contributor-covenant.org/translations.
-5
View File
@@ -1,5 +0,0 @@
# Security Policy
## Reporting a Vulnerability
Please report security issues to `security@immich.app`
+1 -1
View File
@@ -154,7 +154,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:8436e10bc65c94886a91d4415b6a6dfa9cb5a306fb3b996e5bb67cd2b4854193
image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9
healthcheck:
test: redis-cli ping || exit 1
+2 -2
View File
@@ -56,7 +56,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:8436e10bc65c94886a91d4415b6a6dfa9cb5a306fb3b996e5bb67cd2b4854193
image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9
healthcheck:
test: redis-cli ping || exit 1
restart: always
@@ -85,7 +85,7 @@ services:
container_name: immich_prometheus
ports:
- 9090:9090
image: prom/prometheus@sha256:e4254400b85610324913f0dc4acf92603d9984e7519414c5a12811aa6146acc3
image: prom/prometheus@sha256:69f5241418838263316593f7274a304b095c40bcf22e57272865da91bd60a8ac
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
+1 -1
View File
@@ -61,7 +61,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:8436e10bc65c94886a91d4415b6a6dfa9cb5a306fb3b996e5bb67cd2b4854193
image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9
user: '1000:1000'
security_opt:
- no-new-privileges:true
+1 -1
View File
@@ -49,7 +49,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:8436e10bc65c94886a91d4415b6a6dfa9cb5a306fb3b996e5bb67cd2b4854193
image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9
healthcheck:
test: redis-cli ping || exit 1
restart: always
+1 -1
View File
@@ -112,7 +112,7 @@ services:
traefik.enable: true
# increase readingTimeouts for the entrypoint used here
traefik.http.routers.immich.entrypoints: websecure
traefik.http.routers.immich.rule: Host(`immich.your-domain.com`)
traefik.http.routers.immich.rule: Host(`immich.example.com`)
traefik.http.services.immich.loadbalancer.server.port: 2283
```
+1 -1
View File
@@ -90,7 +90,7 @@ immich-admin list-users
[
{
id: 'e65e6f88-2a30-4dbe-8dd9-1885f4889b53',
email: 'immich@example.com.com',
email: 'immich@example.com',
name: 'Immich Admin',
storageLabel: 'admin',
externalPath: null,
+1 -1
View File
@@ -17,7 +17,7 @@ services:
ports:
- "8888:80"
environment:
PGADMIN_DEFAULT_EMAIL: user-name@domain-name.com
PGADMIN_DEFAULT_EMAIL: admin@example.com
PGADMIN_DEFAULT_PASSWORD: strong-password
volumes:
- pgadmin-data:/var/lib/pgadmin
+1 -1
View File
@@ -44,7 +44,7 @@ services:
redis:
container_name: immich-e2e-redis
image: docker.io/valkey/valkey:9@sha256:8436e10bc65c94886a91d4415b6a6dfa9cb5a306fb3b996e5bb67cd2b4854193
image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9
healthcheck:
test: redis-cli ping || exit 1
@@ -492,20 +492,6 @@ describe('/asset', () => {
expect(status).toEqual(200);
});
it('should set the negative rating', async () => {
const { status, body } = await request(app)
.put(`/assets/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ rating: -1 });
expect(body).toMatchObject({
id: user1Assets[0].id,
exifInfo: expect.objectContaining({
rating: -1,
}),
});
expect(status).toEqual(200);
});
it('should return tagged people', async () => {
const { status, body } = await request(app)
.put(`/assets/${user1Assets[0].id}`)
@@ -259,17 +259,6 @@ describe('/search', () => {
assets: [assetHeic],
}),
},
{
should: "should search city ('')",
deferred: () => ({
dto: {
city: '',
visibility: AssetVisibility.Timeline,
includeNull: true,
},
assets: [assetLast],
}),
},
{
should: 'should search city (null)',
deferred: () => ({
@@ -291,18 +280,6 @@ describe('/search', () => {
assets: [assetDensity],
}),
},
{
should: "should search state ('')",
deferred: () => ({
dto: {
state: '',
visibility: AssetVisibility.Timeline,
withExif: true,
includeNull: true,
},
assets: [assetLast, assetNotocactus],
}),
},
{
should: 'should search state (null)',
deferred: () => ({
@@ -324,17 +301,6 @@ describe('/search', () => {
assets: [assetFalcon],
}),
},
{
should: "should search country ('')",
deferred: () => ({
dto: {
country: '',
visibility: AssetVisibility.Timeline,
includeNull: true,
},
assets: [assetLast],
}),
},
{
should: 'should search country (null)',
deferred: () => ({
@@ -141,6 +141,7 @@ describe('/server', () => {
maintenanceMode: false,
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
minFaces: 3,
});
});
});
+15
View File
@@ -230,6 +230,21 @@ describe('/users', () => {
const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
expect(after).toMatchObject({ download: { includeEmbeddedVideos: true } });
});
it('should update minimum face count to display people', async () => {
const before = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
expect(before).toMatchObject({ people: { minimumFaces: 3 } });
const { status, body } = await request(app)
.put('/users/me/preferences')
.send({ people: { minimumFaces: 2 } })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ people: { minimumFaces: 2 } });
const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
expect(after).toMatchObject({ people: { minimumFaces: 2 } });
});
});
describe('GET /users/:id', () => {
+9
View File
@@ -570,6 +570,7 @@
"asset_added_to_album": "Added to album",
"asset_adding_to_album": "Adding to album…",
"asset_created": "Asset created",
"asset_day_count": "{date}: {count, plural, one {# asset} other {# assets}}",
"asset_description_updated": "Asset description has been updated",
"asset_filename_is_offline": "Asset {filename} is offline",
"asset_has_unassigned_faces": "Asset has unassigned faces",
@@ -699,6 +700,7 @@
"backup_settings_subtitle": "Manage upload settings",
"backup_upload_details_page_more_details": "Tap for more details",
"backward": "Backward",
"battery_optimization_backup_reliability": "Disabling battery optimizations can improve the reliability of background backup",
"biometric_auth_enabled": "Biometric authentication enabled",
"biometric_locked_out": "You are locked out of biometric authentication",
"biometric_no_options": "No biometric options available",
@@ -1399,6 +1401,7 @@
"leave": "Leave",
"leave_album": "Leave album",
"lens_model": "Lens model",
"less": "Less",
"let_others_respond": "Let others respond",
"level": "Level",
"library": "Library",
@@ -1592,6 +1595,8 @@
"merge_people_prompt": "Do you want to merge these people? This action is irreversible.",
"merge_people_successfully": "Merge people successfully",
"merged_people_count": "Merged {count, plural, one {# person} other {# people}}",
"minFaces": "Minimum faces",
"minFaces_description": "The minimum number of recognized faces for a person to be displayed",
"minimize": "Minimize",
"minute": "Minute",
"minutes": "Minutes",
@@ -1687,6 +1692,7 @@
"not_selected": "Not selected",
"notes": "Notes",
"nothing_here_yet": "Nothing here yet",
"notification_backup_reliability": "Enable notifications to improve background backup reliability",
"notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.",
"notification_permission_list_tile_content": "Grant permission to enable notifications.",
"notification_permission_list_tile_enable_button": "Enable Notifications",
@@ -2408,6 +2414,7 @@
"updated_password": "Updated password",
"upload": "Upload",
"upload_concurrency": "Upload concurrency",
"upload_day_count": "{date}: {count, plural, one {# upload} other {# uploads}}",
"upload_details": "Upload Details",
"upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?",
"upload_dialog_title": "Upload Asset",
@@ -2423,6 +2430,8 @@
"upload_to_immich": "Upload to Immich ({count})",
"uploading": "Uploading",
"uploading_media": "Uploading media",
"uploads": "Uploads",
"uploads_count": "{count, plural, one {# upload} other {# uploads}}",
"url": "URL",
"usage": "Usage",
"use_biometric": "Use biometric",
+4 -4
View File
@@ -1,8 +1,8 @@
ARG DEVICE=cpu
FROM python:3.11-bookworm@sha256:970c99f886b839fc8829289040c1845dadaf2cae46b37acc7710333158ec29b4 AS builder-cpu
FROM python:3.11-bookworm@sha256:121d86b6d08752968a7dddbc708849e5f3a839bbff47f32212b46d2a1d842bab AS builder-cpu
FROM python:3.13-slim-trixie@sha256:d168b8d9eb761f4d3fe305ebd04aeb7e7f2de0297cec5fb2f8f6403244621664 AS builder-openvino
FROM python:3.13-slim-trixie@sha256:b04b5d7233d2ad9c379e22ea8927cd1378cd15c60d4ef876c065b25ea8fb3bf3 AS builder-openvino
FROM builder-cpu AS builder-cuda
@@ -39,12 +39,12 @@ RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --extra ${DEVICE} --no-dev --no-editable --no-install-project --compile-bytecode --no-progress --active --link-mode copy
FROM python:3.11-slim-bookworm@sha256:9c6f90801e6b68e772b7c0ca74260cbf7af9f320acec894e26fccdaccfbe3b47 AS prod-cpu
FROM python:3.11-slim-bookworm@sha256:8dca233de9f3d9bb410665f00a4da6dd06f331083137e0e98ccf227236fcc438 AS prod-cpu
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
MACHINE_LEARNING_MODEL_ARENA=false
FROM python:3.13-slim-trixie@sha256:d168b8d9eb761f4d3fe305ebd04aeb7e7f2de0297cec5fb2f8f6403244621664 AS prod-openvino
FROM python:3.13-slim-trixie@sha256:b04b5d7233d2ad9c379e22ea8927cd1378cd15c60d4ef876c065b25ea8fb3bf3 AS prod-openvino
RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
+1
View File
@@ -49,6 +49,7 @@ try:
str(settings.http_keepalive_timeout_s),
"--graceful-timeout",
"10",
"--no-control-socket",
],
) as cmd:
cmd.wait()
+2 -1
View File
@@ -12,7 +12,7 @@ from zipfile import BadZipFile
import orjson
from fastapi import Depends, FastAPI, File, Form, HTTPException
from fastapi.responses import ORJSONResponse, PlainTextResponse
from fastapi.responses import PlainTextResponse
from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf, NoSuchFile
from PIL.Image import Image
from pydantic import ValidationError
@@ -32,6 +32,7 @@ from .schemas import (
ModelIdentity,
ModelTask,
ModelType,
ORJSONResponse,
PipelineRequest,
T,
)
@@ -89,7 +89,9 @@ class OpenClipTextualEncoder(BaseCLIPTextualEncoder):
tokenizer: Tokenizer = Tokenizer.from_file(self.tokenizer_file_path.as_posix())
pad_id: int = tokenizer.token_to_id(pad_token)
pad_id = tokenizer.token_to_id(pad_token)
if pad_id is None:
raise ValueError(f"Pad token '{pad_token}' not found in tokenizer vocab")
tokenizer.enable_padding(length=context_length, pad_token=pad_token, pad_id=pad_id)
tokenizer.enable_truncation(max_length=context_length)
+7
View File
@@ -3,9 +3,16 @@ from typing import Any, Literal, Protocol, TypeGuard, TypeVar
import numpy as np
import numpy.typing as npt
import orjson
from fastapi.responses import JSONResponse
from typing_extensions import TypedDict
class ORJSONResponse(JSONResponse):
def render(self, content: Any) -> bytes:
return orjson.dumps(content, option=orjson.OPT_SERIALIZE_NUMPY)
class StrEnum(str, Enum):
value: str
+16
View File
@@ -816,6 +816,10 @@ class TestFaceRecognition:
def test_recognition(self, cv_image: cv2.Mat, mocker: MockerFixture) -> None:
mocker.patch.object(FaceRecognizer, "load")
mocker.patch(
"immich_ml.models.facial_recognition.recognition.ort.get_available_providers",
return_value=["CPUExecutionProvider"],
)
face_recognizer = FaceRecognizer("buffalo_s", min_score=0.0, cache_dir="test_cache")
num_faces = 2
@@ -860,6 +864,10 @@ class TestFaceRecognition:
)
mocker.patch("immich_ml.models.base.InferenceModel.download")
mocker.patch("immich_ml.models.facial_recognition.recognition.ArcFaceONNX")
mocker.patch(
"immich_ml.models.facial_recognition.recognition.ort.get_available_providers",
return_value=["CPUExecutionProvider"],
)
ort_session.return_value.get_inputs.return_value = [SimpleNamespace(name="input.1", shape=(1, 3, 224, 224))]
ort_session.return_value.get_outputs.return_value = [SimpleNamespace(name="output.1", shape=(1, 800))]
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
@@ -894,6 +902,10 @@ class TestFaceRecognition:
)
mocker.patch("immich_ml.models.base.InferenceModel.download")
mocker.patch("immich_ml.models.facial_recognition.recognition.ArcFaceONNX")
mocker.patch(
"immich_ml.models.facial_recognition.recognition.ort.get_available_providers",
return_value=["CPUExecutionProvider"],
)
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
inputs = [SimpleNamespace(name="input.1", shape=("batch", 3, 224, 224))]
@@ -996,6 +1008,10 @@ class TestFaceRecognition:
def test_ignore_other_custom_max_batch_size(self, mocker: MockerFixture) -> None:
mocker.patch.object(settings, "max_batch_size", MaxBatchSize(ocr=2))
mocker.patch(
"immich_ml.models.facial_recognition.recognition.ort.get_available_providers",
return_value=["CPUExecutionProvider"],
)
recognizer = FaceRecognizer("buffalo_l", cache_dir="test_cache")
+498 -450
View File
File diff suppressed because it is too large Load Diff
+28 -93
View File
@@ -1,74 +1,5 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools."aqua:flutter/flutter"]]
version = "3.44.0"
backend = "aqua:flutter/flutter"
[tools."aqua:flutter/flutter"."platforms.linux-arm64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.0-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-arm64-musl"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.0-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.0-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-x64-musl"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.0-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.macos-arm64"]
checksum = "blake3:fb03aa5d9790205c948922ec3f0751c16e4575b09d6ae9dd4fbeb664a69f0e00"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_3.44.0-stable.zip"
[tools."aqua:flutter/flutter"."platforms.macos-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_3.44.0-stable.zip"
[tools."aqua:flutter/flutter"."platforms.windows-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_3.44.0-stable.zip"
[[tools.flutter]]
version = "3.41.9-stable"
backend = "asdf:flutter"
[[tools."github:CQLabs/homebrew-dcm"]]
version = "1.37.0"
backend = "github:CQLabs/homebrew-dcm"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-arm64"]
checksum = "sha256:253da2512b149913dfe345bf9a62a79acb2d730f66e71162ba4a92dfc4224b82"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543838"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-arm64-musl"]
checksum = "sha256:253da2512b149913dfe345bf9a62a79acb2d730f66e71162ba4a92dfc4224b82"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543838"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-x64"]
checksum = "sha256:477e086d4099c12f21e5ccd83b005d5fb945dd4cac4fd127fd9a08d7649af1cf"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543797"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-x64-musl"]
checksum = "sha256:477e086d4099c12f21e5ccd83b005d5fb945dd4cac4fd127fd9a08d7649af1cf"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543797"
[tools."github:CQLabs/homebrew-dcm"."platforms.macos-arm64"]
checksum = "sha256:30bede64367d09067093cc57af6ec9496d7717898138ded5cb98a16ac8dd9d93"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-macos-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543757"
[tools."github:CQLabs/homebrew-dcm"."platforms.macos-x64"]
checksum = "sha256:e56cb99872be7445a4de1d37e5438ca70e3bcd83be7a2b9b385e3538881f8068"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-macos-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543727"
[tools."github:CQLabs/homebrew-dcm"."platforms.windows-x64"]
checksum = "sha256:f133470daa3fb0427f039b424392af7e917d7e7db6b556aa2a968ab0e31587da"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-windows-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543660"
[[tools."github:extism/cli"]]
version = "1.6.3"
backend = "github:extism/cli"
@@ -225,30 +156,6 @@ checksum = "sha256:b5e1d2a1ad3c03229ddc89823848f4a1c11f9c6402a51fa26f0aaa5f1d7a2
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-windows.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288925833"
[[tools.java]]
version = "21.0.2"
backend = "core:java"
[tools.java."platforms.linux-arm64"]
checksum = "sha256:08db1392a48d4eb5ea5315cf8f18b89dbaf36cda663ba882cf03c704c9257ec2"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-aarch64_bin.tar.gz"
[tools.java."platforms.linux-x64"]
checksum = "sha256:a2def047a73941e01a73739f92755f86b895811afb1f91243db214cff5bdac3f"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-x64_bin.tar.gz"
[tools.java."platforms.macos-arm64"]
checksum = "sha256:b3d588e16ec1e0ef9805d8a696591bd518a5cea62567da8f53b5ce32d11d22e4"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-aarch64_bin.tar.gz"
[tools.java."platforms.macos-x64"]
checksum = "sha256:8fd09e15dc406387a0aba70bf5d99692874e999bf9cd9208b452b5d76ac922d3"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-x64_bin.tar.gz"
[tools.java."platforms.windows-x64"]
checksum = "sha256:b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip"
[[tools.node]]
version = "24.15.0"
backend = "core:node"
@@ -321,6 +228,34 @@ url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.
version = "10.33.4"
backend = "aqua:pnpm/pnpm"
[tools.pnpm."platforms.linux-arm64"]
checksum = "sha256:d29649c7380b5cd522f574208fbd35335846686498f45004604d3f5b8658b5cb"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-linux-arm64"
[tools.pnpm."platforms.linux-arm64-musl"]
checksum = "sha256:d29649c7380b5cd522f574208fbd35335846686498f45004604d3f5b8658b5cb"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-linux-arm64"
[tools.pnpm."platforms.linux-x64"]
checksum = "sha256:ff1795595535a10d0dfe327303f3dd02377be141190b1f5756de68edde2cf813"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-linux-x64"
[tools.pnpm."platforms.linux-x64-musl"]
checksum = "sha256:ff1795595535a10d0dfe327303f3dd02377be141190b1f5756de68edde2cf813"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-linux-x64"
[tools.pnpm."platforms.macos-arm64"]
checksum = "sha256:7aae186a04e1ffaa0047d43cd07d68a98dec303304f28be52234ba955d26c671"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-macos-arm64"
[tools.pnpm."platforms.macos-x64"]
checksum = "sha256:3b0c97b9f794cdda293949a8ee0e0151ca08f512f4a832408386221c7c73eec6"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-macos-x64"
[tools.pnpm."platforms.windows-x64"]
checksum = "sha256:3268b2f29defe0dce8a3a26c0ef01488f0d4aa4872923173186ef618ab7d68ef"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-win-x64.exe"
[[tools.terragrunt]]
version = "1.0.3"
backend = "aqua:gruntwork-io/terragrunt"
-14
View File
@@ -16,28 +16,14 @@ config_roots = [
[tools]
node = "24.15.0"
"aqua:flutter/flutter" = "3.44.0"
pnpm = "10.33.4"
terragrunt = "1.0.3"
opentofu = "1.11.6"
java = "21.0.2"
"npm:oazapfts" = "7.5.0"
"github:extism/cli" = "1.6.3"
"github:webassembly/binaryen" = "version_124"
"github:extism/js-pdk" = "1.6.0"
[tools."github:CQLabs/homebrew-dcm"]
version = "1.37.0"
bin = "dcm"
postinstall = "chmod +x \"$MISE_TOOL_INSTALL_PATH/dcm\" || true"
[tools."github:CQLabs/homebrew-dcm".platforms]
linux-x64 = { asset_pattern = "dcm-linux-x64-release.zip" }
linux-arm64 = { asset_pattern = "dcm-linux-arm-release.zip" }
macos-x64 = { asset_pattern = "dcm-macos-x64-release.zip" }
macos-arm64 = { asset_pattern = "dcm-macos-arm-release.zip" }
windows-x64 = { asset_pattern = "dcm-windows-release.zip" }
[tools."github:jellyfin/jellyfin-ffmpeg"]
version = "7.1.3-6"
@@ -89,6 +89,20 @@
<data android:mimeType="video/*" />
</intent-filter>
<!-- Allow Immich to act as an image viewer -->
<intent-filter android:label="View in Immich">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="content" android:mimeType="image/*" />
</intent-filter>
<!-- Allow Immich to act as a video viewer -->
<intent-filter android:label="View in Immich">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="content" android:mimeType="video/*" />
</intent-filter>
<!-- immich:// URL scheme handling -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
@@ -1,6 +1,7 @@
package app.alextran.immich
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.ext.SdkExtensions
import app.alextran.immich.background.BackgroundEngineLock
@@ -22,6 +23,7 @@ import app.alextran.immich.permission.PermissionApiImpl
import app.alextran.immich.sync.NativeSyncApi
import app.alextran.immich.sync.NativeSyncApiImpl26
import app.alextran.immich.sync.NativeSyncApiImpl30
import app.alextran.immich.viewintent.ViewIntentPlugin
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
@@ -31,6 +33,11 @@ class MainActivity : FlutterFragmentActivity() {
registerPlugins(this, flutterEngine)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
}
companion object {
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
HttpClientManager.initialize(ctx)
@@ -55,6 +62,7 @@ class MainActivity : FlutterFragmentActivity() {
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
flutterEngine.plugins.add(ViewIntentPlugin())
flutterEngine.plugins.add(backgroundEngineLockImpl)
flutterEngine.plugins.add(nativeSyncApiImpl)
flutterEngine.plugins.add(permissionApiImpl)
@@ -47,18 +47,44 @@ class FlutterError (
override val message: String? = null,
val details: Any? = null
) : RuntimeException()
enum class PermissionStatus(val raw: Int) {
GRANTED(0),
DENIED(1),
PERMANENTLY_DENIED(2);
companion object {
fun ofRaw(raw: Int): PermissionStatus? {
return values().firstOrNull { it.raw == raw }
}
}
}
private open class PermissionApiPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return super.readValueOfType(type, buffer)
return when (type) {
129.toByte() -> {
return (readValue(buffer) as Long?)?.let {
PermissionStatus.ofRaw(it.toInt())
}
}
else -> super.readValueOfType(type, buffer)
}
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
super.writeValue(stream, value)
when (value) {
is PermissionStatus -> {
stream.write(129)
writeValue(stream, value.raw.toLong())
}
else -> super.writeValue(stream, value)
}
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface PermissionApi {
fun isIgnoringBatteryOptimizations(): PermissionStatus
fun hasManageMediaPermission(): Boolean
fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit)
fun manageMediaPermission(callback: (Result<Boolean>) -> Unit)
@@ -72,6 +98,21 @@ interface PermissionApi {
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.isIgnoringBatteryOptimizations$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.isIgnoringBatteryOptimizations())
} catch (exception: Throwable) {
PermissionApiPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission$separatedMessageChannelSuffix", codec)
if (api != null) {
@@ -1,13 +1,26 @@
package app.alextran.immich.permission
import android.content.Context
import android.os.PowerManager
import app.alextran.immich.core.ImmichPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
class PermissionApiImpl(context: Context) : ImmichPlugin(), PermissionApi, ActivityAware {
private val ctx: Context = context.applicationContext
private val manageMediaPermissionDelegate = ManageMediaPermissionDelegate(context)
private val powerManager =
ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
override fun isIgnoringBatteryOptimizations(): PermissionStatus {
if (powerManager.isIgnoringBatteryOptimizations(ctx.packageName)) {
return PermissionStatus.GRANTED
}
return PermissionStatus.DENIED
}
override fun hasManageMediaPermission(): Boolean =
manageMediaPermissionDelegate.hasManageMediaPermission()
@@ -542,16 +542,17 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface NativeSyncApi {
fun shouldFullSync(): Boolean
fun getMediaChanges(): SyncDelta
fun shouldFullSync(callback: (Result<Boolean>) -> Unit)
fun getMediaChanges(callback: (Result<SyncDelta>) -> Unit)
fun checkpointSync()
fun clearSyncCheckpoint()
fun getAssetIdsForAlbum(albumId: String): List<String>
fun getAlbums(): List<PlatformAlbum>
fun getAssetIdsForAlbum(albumId: String, callback: (Result<List<String>>) -> Unit)
fun getAlbums(callback: (Result<List<PlatformAlbum>>) -> Unit)
fun getAssetsCountSince(albumId: String, timestamp: Long): Long
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset>
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?, callback: (Result<List<PlatformAsset>>) -> Unit)
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
fun cancelHashing()
fun cancelSync()
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit)
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
@@ -570,27 +571,33 @@ interface NativeSyncApi {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.shouldFullSync())
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
api.shouldFullSync{ result: Result<Boolean> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$separatedMessageChannelSuffix", codec, taskQueue)
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.getMediaChanges())
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
api.getMediaChanges{ result: Result<SyncDelta> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
@@ -629,32 +636,38 @@ interface NativeSyncApi {
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum$separatedMessageChannelSuffix", codec, taskQueue)
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val albumIdArg = args[0] as String
val wrapped: List<Any?> = try {
listOf(api.getAssetIdsForAlbum(albumIdArg))
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
api.getAssetIdsForAlbum(albumIdArg) { result: Result<List<String>> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$separatedMessageChannelSuffix", codec, taskQueue)
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.getAlbums())
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
api.getAlbums{ result: Result<List<PlatformAlbum>> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
@@ -679,18 +692,21 @@ interface NativeSyncApi {
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$separatedMessageChannelSuffix", codec, taskQueue)
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val albumIdArg = args[0] as String
val updatedTimeCondArg = args[1] as Long?
val wrapped: List<Any?> = try {
listOf(api.getAssetsForAlbum(albumIdArg, updatedTimeCondArg))
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
api.getAssetsForAlbum(albumIdArg, updatedTimeCondArg) { result: Result<List<PlatformAsset>> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
@@ -733,6 +749,22 @@ interface NativeSyncApi {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelSync$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
api.cancelSync()
listOf(null)
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
@@ -4,7 +4,11 @@ import android.content.Context
class NativeSyncApiImpl26(context: Context) : NativeSyncApiImplBase(context), NativeSyncApi {
override fun shouldFullSync(): Boolean {
override fun shouldFullSync(callback: (Result<Boolean>) -> Unit) {
runSync(callback) { shouldFullSync() }
}
private fun shouldFullSync(): Boolean {
return true
}
@@ -18,7 +22,11 @@ class NativeSyncApiImpl26(context: Context) : NativeSyncApiImplBase(context), Na
// No-op for Android 10 and below
}
override fun getMediaChanges(): SyncDelta {
override fun getMediaChanges(callback: (Result<SyncDelta>) -> Unit) {
runSync(callback) { getMediaChanges() }
}
private fun getMediaChanges(): SyncDelta {
throw IllegalStateException("Method not supported on this Android version.")
}
@@ -7,6 +7,8 @@ import android.os.Bundle
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import androidx.annotation.RequiresExtension
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.serialization.json.Json
@RequiresApi(Build.VERSION_CODES.Q)
@@ -35,7 +37,11 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
}
}
override fun shouldFullSync(): Boolean =
override fun shouldFullSync(callback: (Result<Boolean>) -> Unit) {
runSync(callback) { shouldFullSync() }
}
private fun shouldFullSync(): Boolean =
MediaStore.getVersion(ctx) != prefs.getString(SHARED_PREF_MEDIA_STORE_VERSION_KEY, null)
override fun checkpointSync() {
@@ -49,7 +55,11 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
}
}
override fun getMediaChanges(): SyncDelta {
override fun getMediaChanges(callback: (Result<SyncDelta>) -> Unit) {
runSync(callback) { getMediaChanges() }
}
private suspend fun getMediaChanges(): SyncDelta {
val genMap = getSavedGenerationMap()
val currentVolumes = MediaStore.getExternalVolumeNames(ctx)
val changed = mutableListOf<PlatformAsset>()
@@ -58,6 +68,7 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
var hasChanges = genMap.keys != currentVolumes
for (volume in currentVolumes) {
currentCoroutineContext().ensureActive()
val currentGen = MediaStore.getGeneration(ctx, volume)
val storedGen = genMap[volume] ?: 0
if (currentGen <= storedGen) {
@@ -45,12 +45,14 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
private val ctx: Context = context.applicationContext
private var hashTask: Job? = null
private var syncJob: Job? = null
private val mediaTrashDelegate = MediaTrashDelegate(ctx)
companion object {
private const val MAX_CONCURRENT_HASH_OPERATIONS = 16
private val hashSemaphore = Semaphore(MAX_CONCURRENT_HASH_OPERATIONS)
private const val HASHING_CANCELLED_CODE = "HASH_CANCELLED"
private const val SYNC_CANCELLED_CODE = "SYNC_CANCELLED"
// MediaStore.Files.FileColumns.SPECIAL_FORMAT — S Extensions 21+
// https://developer.android.com/reference/android/provider/MediaStore.Files.FileColumns#SPECIAL_FORMAT
@@ -295,7 +297,11 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
return PlatformAssetPlaybackStyle.IMAGE
}
fun getAlbums(): List<PlatformAlbum> {
fun getAlbums(callback: (Result<List<PlatformAlbum>>) -> Unit) {
runSync(callback) { getAlbums() }
}
private suspend fun getAlbums(): List<PlatformAlbum> {
val albums = mutableListOf<PlatformAlbum>()
val albumsCount = mutableMapOf<String, Int>()
@@ -322,6 +328,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATE_MODIFIED)
while (cursor.moveToNext()) {
currentCoroutineContext().ensureActive()
val id = cursor.getString(bucketIdColumn)
val count = albumsCount.getOrDefault(id, 0)
@@ -342,7 +349,11 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
.sortedBy { it.id }
}
fun getAssetIdsForAlbum(albumId: String): List<String> {
fun getAssetIdsForAlbum(albumId: String, callback: (Result<List<String>>) -> Unit) {
runSync(callback) { getAssetIdsForAlbum(albumId) }
}
private fun getAssetIdsForAlbum(albumId: String): List<String> {
val projection = arrayOf(MediaStore.MediaColumns._ID)
return getCursor(
@@ -366,7 +377,11 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
)?.use { cursor -> cursor.count.toLong() } ?: 0L
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset> {
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?, callback: (Result<List<PlatformAsset>>) -> Unit) {
runSync(callback) { getAssetsForAlbum(albumId, updatedTimeCond) }
}
private fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset> {
var selection = "$BUCKET_SELECTION AND $MEDIA_SELECTION"
val selectionArgs = mutableListOf(albumId, *MEDIA_SELECTION_ARGS)
@@ -451,6 +466,24 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
hashTask = null
}
fun cancelSync() {
syncJob?.cancel()
syncJob = null
}
protected fun <T> runSync(callback: (Result<T>) -> Unit, work: suspend () -> T) {
syncJob?.cancel()
syncJob = CoroutineScope(Dispatchers.IO).launch {
try {
completeWhenActive(callback, Result.success(work()))
} catch (e: CancellationException) {
completeWhenActive(callback, Result.failure(FlutterError(SYNC_CANCELLED_CODE, "Sync cancelled", null)))
} catch (e: Exception) {
completeWhenActive(callback, Result.failure(e))
}
}
}
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit) {
mediaTrashDelegate.restoreFromTrashById(mediaId, type) { completeWhenActive(callback, it) }
}
@@ -0,0 +1,292 @@
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
package app.alextran.immich.viewintent
import android.util.Log
import io.flutter.plugin.common.BasicMessageChannel
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MessageCodec
import io.flutter.plugin.common.StandardMethodCodec
import io.flutter.plugin.common.StandardMessageCodec
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
private object ViewIntentPigeonUtils {
fun wrapResult(result: Any?): List<Any?> {
return listOf(result)
}
fun wrapError(exception: Throwable): List<Any?> {
return if (exception is FlutterError) {
listOf(
exception.code,
exception.message,
exception.details
)
} else {
listOf(
exception.javaClass.simpleName,
exception.toString(),
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
)
}
}
fun doubleEquals(a: Double, b: Double): Boolean {
// Normalize -0.0 to 0.0 and handle NaN equality.
return (if (a == 0.0) 0.0 else a) == (if (b == 0.0) 0.0 else b) || (a.isNaN() && b.isNaN())
}
fun floatEquals(a: Float, b: Float): Boolean {
// Normalize -0.0 to 0.0 and handle NaN equality.
return (if (a == 0.0f) 0.0f else a) == (if (b == 0.0f) 0.0f else b) || (a.isNaN() && b.isNaN())
}
fun doubleHash(d: Double): Int {
// Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes.
val normalized = if (d == 0.0) 0.0 else d
val bits = java.lang.Double.doubleToLongBits(normalized)
return (bits xor (bits ushr 32)).toInt()
}
fun floatHash(f: Float): Int {
// Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes.
val normalized = if (f == 0.0f) 0.0f else f
return java.lang.Float.floatToIntBits(normalized)
}
fun deepEquals(a: Any?, b: Any?): Boolean {
if (a === b) {
return true
}
if (a == null || b == null) {
return false
}
if (a is ByteArray && b is ByteArray) {
return a.contentEquals(b)
}
if (a is IntArray && b is IntArray) {
return a.contentEquals(b)
}
if (a is LongArray && b is LongArray) {
return a.contentEquals(b)
}
if (a is DoubleArray && b is DoubleArray) {
if (a.size != b.size) return false
for (i in a.indices) {
if (!doubleEquals(a[i], b[i])) return false
}
return true
}
if (a is FloatArray && b is FloatArray) {
if (a.size != b.size) return false
for (i in a.indices) {
if (!floatEquals(a[i], b[i])) return false
}
return true
}
if (a is Array<*> && b is Array<*>) {
if (a.size != b.size) return false
for (i in a.indices) {
if (!deepEquals(a[i], b[i])) return false
}
return true
}
if (a is List<*> && b is List<*>) {
if (a.size != b.size) return false
val iterA = a.iterator()
val iterB = b.iterator()
while (iterA.hasNext() && iterB.hasNext()) {
if (!deepEquals(iterA.next(), iterB.next())) return false
}
return true
}
if (a is Map<*, *> && b is Map<*, *>) {
if (a.size != b.size) return false
for (entry in a) {
val key = entry.key
var found = false
for (bEntry in b) {
if (deepEquals(key, bEntry.key)) {
if (deepEquals(entry.value, bEntry.value)) {
found = true
break
} else {
return false
}
}
}
if (!found) return false
}
return true
}
if (a is Double && b is Double) {
return doubleEquals(a, b)
}
if (a is Float && b is Float) {
return floatEquals(a, b)
}
return a == b
}
fun deepHash(value: Any?): Int {
return when (value) {
null -> 0
is ByteArray -> value.contentHashCode()
is IntArray -> value.contentHashCode()
is LongArray -> value.contentHashCode()
is DoubleArray -> {
var result = 1
for (item in value) {
result = 31 * result + doubleHash(item)
}
result
}
is FloatArray -> {
var result = 1
for (item in value) {
result = 31 * result + floatHash(item)
}
result
}
is Array<*> -> {
var result = 1
for (item in value) {
result = 31 * result + deepHash(item)
}
result
}
is List<*> -> {
var result = 1
for (item in value) {
result = 31 * result + deepHash(item)
}
result
}
is Map<*, *> -> {
var result = 0
for (entry in value) {
result += ((deepHash(entry.key) * 31) xor deepHash(entry.value))
}
result
}
is Double -> doubleHash(value)
is Float -> floatHash(value)
else -> value.hashCode()
}
}
}
/**
* Error class for passing custom error details to Flutter via a thrown PlatformException.
* @property code The error code.
* @property message The error message.
* @property details The error details. Must be a datatype supported by the api codec.
*/
class FlutterError (
val code: String,
override val message: String? = null,
val details: Any? = null
) : RuntimeException()
/** Generated class from Pigeon that represents data sent in messages. */
data class ViewIntentPayload (
val path: String? = null,
val mimeType: String,
val localAssetId: String? = null
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): ViewIntentPayload {
val path = pigeonVar_list[0] as String?
val mimeType = pigeonVar_list[1] as String
val localAssetId = pigeonVar_list[2] as String?
return ViewIntentPayload(path, mimeType, localAssetId)
}
}
fun toList(): List<Any?> {
return listOf(
path,
mimeType,
localAssetId,
)
}
override fun equals(other: Any?): Boolean {
if (other == null || other.javaClass != javaClass) {
return false
}
if (this === other) {
return true
}
val other = other as ViewIntentPayload
return ViewIntentPigeonUtils.deepEquals(this.path, other.path) && ViewIntentPigeonUtils.deepEquals(this.mimeType, other.mimeType) && ViewIntentPigeonUtils.deepEquals(this.localAssetId, other.localAssetId)
}
override fun hashCode(): Int {
var result = javaClass.hashCode()
result = 31 * result + ViewIntentPigeonUtils.deepHash(this.path)
result = 31 * result + ViewIntentPigeonUtils.deepHash(this.mimeType)
result = 31 * result + ViewIntentPigeonUtils.deepHash(this.localAssetId)
return result
}
}
private open class ViewIntentPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
129.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
ViewIntentPayload.fromList(it)
}
}
else -> super.readValueOfType(type, buffer)
}
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
when (value) {
is ViewIntentPayload -> {
stream.write(129)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
}
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface ViewIntentHostApi {
fun consumeViewIntent(callback: (Result<ViewIntentPayload?>) -> Unit)
companion object {
/** The codec used by ViewIntentHostApi. */
val codec: MessageCodec<Any?> by lazy {
ViewIntentPigeonCodec()
}
/** Sets up an instance of `ViewIntentHostApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: ViewIntentHostApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ViewIntentHostApi.consumeViewIntent$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.consumeViewIntent{ result: Result<ViewIntentPayload?> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(ViewIntentPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(ViewIntentPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
@@ -0,0 +1,201 @@
package app.alextran.immich.viewintent
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.DocumentsContract
import android.provider.MediaStore
import android.provider.OpenableColumns
import android.util.Log
import android.webkit.MimeTypeMap
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.PluginRegistry
import java.io.File
import java.io.FileOutputStream
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
private const val TAG = "ViewIntentPlugin"
class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentListener, ViewIntentHostApi {
private var context: Context? = null
private var activity: Activity? = null
private var unconsumedIntent: Intent? = null
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
context = binding.applicationContext
ViewIntentHostApi.setUp(binding.binaryMessenger, this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
ViewIntentHostApi.setUp(binding.binaryMessenger, null)
ioScope.cancel()
context = null
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
unconsumedIntent = binding.activity.intent
binding.addOnNewIntentListener(this)
}
override fun onDetachedFromActivityForConfigChanges() {
activity = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
onAttachedToActivity(binding)
}
override fun onDetachedFromActivity() {
activity = null
}
override fun onNewIntent(intent: Intent): Boolean {
unconsumedIntent = intent
return false
}
override fun consumeViewIntent(callback: (Result<ViewIntentPayload?>) -> Unit) {
val context = context ?: run {
callback(Result.success(null))
return
}
val intent = unconsumedIntent ?: activity?.intent
if (intent?.action != Intent.ACTION_VIEW) {
callback(Result.success(null))
return
}
val uri = intent.data
if (uri == null) {
callback(Result.success(null))
return
}
ioScope.launch {
try {
val mimeType = context.contentResolver.getType(uri) ?: intent.type
if (mimeType == null || (!mimeType.startsWith("image/") && !mimeType.startsWith("video/"))) {
callback(Result.success(null))
return@launch
}
val localAssetId = extractLocalAssetId(context, uri, mimeType)
val tempFilePath = if (localAssetId == null) {
copyUriToTempFile(context, uri, mimeType)?.absolutePath ?: run {
callback(Result.success(null))
return@launch
}
} else {
null
}
val payload = ViewIntentPayload(
path = tempFilePath,
mimeType = mimeType,
localAssetId = localAssetId,
)
consumeViewIntent(intent)
callback(Result.success(payload))
} catch (e: Exception) {
callback(Result.failure(e))
}
}
}
private fun consumeViewIntent(currentIntent: Intent) {
unconsumedIntent = Intent(currentIntent).apply {
action = null
data = null
type = null
}
activity?.intent = unconsumedIntent
}
private fun extractLocalAssetId(context: Context, uri: Uri, mimeType: String): String? {
return tryExtractDocumentLocalAssetId(context, uri)
?: tryParseContentUriId(uri)
?: resolveLocalIdByNameAndSize(context, uri, mimeType)
}
private fun tryExtractDocumentLocalAssetId(context: Context, uri: Uri): String? {
return try {
if (!DocumentsContract.isDocumentUri(context, uri)) return null
val docId = DocumentsContract.getDocumentId(uri)
if (docId.isBlank() || docId.startsWith("raw:")) return null
docId.substringAfter(':', docId).toLongOrNull()?.toString()
} catch (e: Exception) {
Log.w(TAG, "Failed to resolve local asset id from document URI: $uri", e)
null
}
}
private fun tryParseContentUriId(uri: Uri): String? {
val id = uri.lastPathSegment?.toLongOrNull() ?: return null
return if (id >= 0) id.toString() else null
}
private fun copyUriToTempFile(context: Context, uri: Uri, mimeType: String): File? {
return try {
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)?.let { ".$it" }
val tempFile = File.createTempFile("view_intent_", extension, context.cacheDir)
context.contentResolver.openInputStream(uri)?.use { inputStream ->
FileOutputStream(tempFile).use { outputStream ->
inputStream.copyTo(outputStream)
}
} ?: return null
tempFile
} catch (_: Exception) {
null
}
}
private fun resolveLocalIdByNameAndSize(context: Context, uri: Uri, mimeType: String): String? {
val metaProjection = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
val (displayName, size) =
try {
context.contentResolver.query(uri, metaProjection, null, null, null)?.use { cursor ->
if (!cursor.moveToFirst()) return null
val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE)
val name = if (nameIdx >= 0) cursor.getString(nameIdx) else null
val bytes = if (sizeIdx >= 0) cursor.getLong(sizeIdx) else -1L
if (name.isNullOrBlank() || bytes < 0) return null
name to bytes
} ?: return null
} catch (_: Exception) {
return null
}
val tableUri = when {
mimeType.startsWith("image/") -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
mimeType.startsWith("video/") -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
else -> return null
}
return try {
context.contentResolver
.query(
tableUri,
arrayOf(MediaStore.MediaColumns._ID),
"${MediaStore.MediaColumns.DISPLAY_NAME}=? AND ${MediaStore.MediaColumns.SIZE}=?",
arrayOf(displayName, size.toString()),
"${MediaStore.MediaColumns.DATE_MODIFIED} DESC",
)?.use { cursor ->
if (!cursor.moveToFirst()) return null
val idIndex = cursor.getColumnIndex(MediaStore.MediaColumns._ID)
if (idIndex < 0) return null
cursor.getLong(idIndex).toString()
}
} catch (_: Exception) {
null
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,154 @@
import 'dart:async';
import 'package:drift/drift.dart' show Value;
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/main.dart' as app;
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/wm_executor.dart';
import 'package:integration_test/integration_test.dart';
import 'package:openapi/api.dart';
import 'test_utils/fake_immich_server.dart';
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
// These tests do real I/O without pumping a widget tree, so disable the fake async clock
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
late Drift drift;
late FakeImmichServer server;
setUpAll(() async {
await app.initApp();
(drift, _) = await Bootstrap.initDomain();
});
setUp(() async {
await workerManagerPatch.init(dynamicSpawning: true);
server = await FakeImmichServer.start();
await ApiService().resolveAndSetEndpoint(server.endpoint);
await drift.delete(drift.userEntity).go();
await Store.delete(StoreKey.syncMigrationStatus);
});
tearDown(() async {
await workerManagerPatch.dispose();
await server.close();
await Store.delete(StoreKey.serverEndpoint);
await Store.delete(StoreKey.syncMigrationStatus);
});
void sendUser(SyncStream stream, String id, String name) {
stream.send(
type: SyncEntityType.userV1.value,
data: SyncUserV1(
id: id,
name: name,
email: '$id@test.com',
hasProfileImage: false,
deletedAt: null,
profileChangedAt: DateTime.utc(2025),
).toJson(),
ack: id,
);
}
Future<bool> dbReadable() async {
try {
await drift.customSelect('SELECT 1').get().timeout(const Duration(seconds: 5));
return true;
} catch (_) {
return false;
}
}
Future<int> userCount() async => (await drift.select(drift.userEntity).get()).length;
// Starts a remote sync and resolves once its /sync/stream request is open.
Future<(Future<bool>, SyncStream)> startSync() async {
final sync = BackgroundSyncManager().syncRemote();
final stream = await server.streamOpened.timeout(
const Duration(seconds: 30),
onTimeout: () => fail('sync isolate never opened /sync/stream'),
);
return (sync, stream);
}
testWidgets('a full sync ingests streamed events into the shared DB', (tester) async {
expect(await userCount(), 0);
final (sync, stream) = await startSync();
sendUser(stream, 'u1', 'Alice');
sendUser(stream, 'u2', 'Bob');
await stream.close();
final result = await sync.timeout(
const Duration(seconds: 30),
onTimeout: () => fail('sync did not complete after the stream ended'),
);
expect(result, isTrue);
expect(await userCount(), 2);
expect(server.ackRequests, greaterThan(0));
});
testWidgets('disposing the pool during an in-flight sync drains promptly', (tester) async {
final (sync, _) = await startSync();
final sw = Stopwatch()..start();
await workerManagerPatch.dispose().timeout(
const Duration(seconds: 15),
onTimeout: () => fail('dispose() hung — worker did not drain and exit'),
);
expect(sw.elapsed, lessThan(const Duration(seconds: 10)), reason: 'abort-driven, not socket-timeout bound');
expect(await sync.timeout(const Duration(seconds: 5), onTimeout: () => false), isFalse);
});
testWidgets('tearing down a worker blocked mid-write leaves the DB usable', (tester) async {
final (sync, stream) = await startSync();
// Hold an exclusive write transaction so the worker's write is blocked. The lock is taken only
// after the stream opens to avoid blocking the worker's own startup DB reads.
final releaseTxn = Completer<void>();
final txnHeld = Completer<void>();
final txn = drift.transaction(() async {
await drift.into(drift.userEntity).insert(
UserEntityCompanion.insert(
id: 'holder',
name: 'holder',
email: 'holder@test.com',
hasProfileImage: const Value(false),
profileChangedAt: Value(DateTime.utc(2025)),
),
);
txnHeld.complete();
await releaseTxn.future;
});
await txnHeld.future;
sendUser(stream, 'u1', 'Alice');
await stream.close();
// dispose() can only finish once the worker unwinds, which is blocked on the
// lock — so start it, release the lock, then await completion.
final disposed = workerManagerPatch.dispose();
releaseTxn.complete();
await txn;
await disposed.timeout(
const Duration(seconds: 15),
onTimeout: () => fail('dispose() hung after releasing the write lock'),
);
await sync.timeout(const Duration(seconds: 5), onTimeout: () => false);
expect(await dbReadable(), isTrue);
final users = await drift.select(drift.userEntity).get();
expect(users.map((u) => u.id), contains('holder'));
});
}
@@ -0,0 +1,115 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
/// A dummy localhost server that implements only the endpoints that remote-sync touches.
class FakeImmichServer {
FakeImmichServer._(this._server, this.version);
final HttpServer _server;
final (int, int, int) version;
final Completer<SyncStream> _streamOpened = Completer<SyncStream>();
int ackRequests = 0;
String get endpoint => 'http://${_server.address.host}:${_server.port}/api';
/// Resolves when the sync isolate opens `POST /sync/stream`.
Future<SyncStream> get streamOpened => _streamOpened.future;
static Future<FakeImmichServer> start({(int, int, int) version = (3, 0, 0)}) async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final fake = FakeImmichServer._(server, version);
fake._listen();
return fake;
}
void _listen() {
// A connection torn down mid-write during teardown is expected
_server.listen((request) => unawaited(_route(request).catchError((_) {})));
}
Future<void> _route(HttpRequest request) async {
final method = request.method;
final path = request.uri.path;
if (method == 'GET' && path == '/api/server/ping') {
return _respondJson(request, {'res': 'pong'});
}
if (method == 'GET' && path == '/api/server/version') {
final (major, minor, patch) = version;
return _respondJson(request, {'major': major, 'minor': minor, 'patch': patch});
}
if (path == '/api/sync/ack') {
if (method != 'DELETE') {
ackRequests++;
}
return _respondEmpty(request);
}
if (method == 'POST' && path == '/api/sync/stream') {
return _openSyncStream(request);
}
return _respondEmpty(request, status: HttpStatus.notFound);
}
Future<void> _openSyncStream(HttpRequest request) async {
await request.drain<void>();
request.response
..statusCode = HttpStatus.ok
..headers.contentType = ContentType('application', 'jsonlines+json')
..contentLength = -1 // chunked: stays open to stream incrementally
..bufferOutput = false;
// Flush headers so the client's send() resolves and enters its read loop.
await request.response.flush();
if (!_streamOpened.isCompleted) {
_streamOpened.complete(SyncStream._(request.response));
}
}
Future<void> _respondJson(HttpRequest request, Object body) async {
await request.drain<void>();
request.response
..statusCode = HttpStatus.ok
..headers.contentType = ContentType.json
..write(jsonEncode(body));
await request.response.close();
}
Future<void> _respondEmpty(HttpRequest request, {int status = HttpStatus.ok}) async {
await request.drain<void>();
request.response.statusCode = status;
await request.response.close();
}
Future<void> close() async {
if (_streamOpened.isCompleted) {
await (await _streamOpened.future).close();
}
await _server.close(force: true);
}
}
/// Handle to the open `/sync/stream` response: push jsonlines events, then end.
class SyncStream {
SyncStream._(this._response);
final HttpResponse _response;
bool _closed = false;
/// [data] should be a Sync*V1 DTO's `toJson()` so the parser's `fromJson` round-trips it.
void send({required String type, required Object data, required String ack}) {
if (_closed) {
return;
}
_response.write('${jsonEncode({'type': type, 'data': data, 'ack': ack})}\n');
}
Future<void> close() async {
if (_closed) {
return;
}
_closed = true;
await _response.close();
}
}
+2 -1
View File
@@ -2,4 +2,5 @@ source "https://rubygems.org"
gem "fastlane"
gem "cocoapods"
gem "abbrev" # Required for Ruby 3.4+
gem "abbrev" # Required for Ruby 3.4+
gem "multi_json"
@@ -121,8 +121,8 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
/**
* Cancels the currently running background task, either due to timeout or external request.
* Sends a cancel signal to the Flutter side and sets up a fallback timer to ensure
* the completion handler is eventually called even if Flutter doesn't respond.
* Only tears down the engine after Dart confirms it's drained. If Dart overruns iOS's grace window,
* the expiration handler still calls setTaskCompleted and iOS suspends us.
*/
func close() {
if isComplete {
@@ -132,12 +132,6 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
flutterApi?.cancel { result in
self.complete(success: false)
}
// Fallback safety mechanism: ensure completion is called within 2 seconds
// This prevents the background task from hanging indefinitely if Flutter doesn't respond
Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in
self.complete(success: false)
}
}
+81 -1
View File
@@ -11,6 +11,24 @@ import Foundation
#error("Unsupported platform.")
#endif
/// Error class for passing custom error details to Dart side.
final class PigeonError: Error {
let code: String
let message: String?
let details: Sendable?
init(code: String, message: String?, details: Sendable?) {
self.code = code
self.message = message
self.details = details
}
var localizedDescription: String {
return
"PigeonError(code: \(code), message: \(message ?? "<nil>"), details: \(details ?? "<nil>")"
}
}
private func wrapResult(_ result: Any?) -> [Any?] {
return [result]
}
@@ -46,8 +64,57 @@ private func nilOrValue<T>(_ value: Any?) -> T? {
return value as! T?
}
enum PermissionStatus: Int {
case granted = 0
case denied = 1
case permanentlyDenied = 2
}
private class PermissionApiPigeonCodecReader: FlutterStandardReader {
override func readValue(ofType type: UInt8) -> Any? {
switch type {
case 129:
let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
if let enumResultAsInt = enumResultAsInt {
return PermissionStatus(rawValue: enumResultAsInt)
}
return nil
default:
return super.readValue(ofType: type)
}
}
}
private class PermissionApiPigeonCodecWriter: FlutterStandardWriter {
override func writeValue(_ value: Any) {
if let value = value as? PermissionStatus {
super.writeByte(129)
super.writeValue(value.rawValue)
} else {
super.writeValue(value)
}
}
}
private class PermissionApiPigeonCodecReaderWriter: FlutterStandardReaderWriter {
override func reader(with data: Data) -> FlutterStandardReader {
return PermissionApiPigeonCodecReader(data: data)
}
override func writer(with data: NSMutableData) -> FlutterStandardWriter {
return PermissionApiPigeonCodecWriter(data: data)
}
}
class PermissionApiPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
static let shared = PermissionApiPigeonCodec(readerWriter: PermissionApiPigeonCodecReaderWriter())
}
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol PermissionApi {
func isIgnoringBatteryOptimizations() throws -> PermissionStatus
func hasManageMediaPermission() throws -> Bool
func requestManageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void)
func manageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void)
@@ -55,10 +122,23 @@ protocol PermissionApi {
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
class PermissionApiSetup {
static var codec: FlutterStandardMessageCodec { FlutterStandardMessageCodec.sharedInstance() }
static var codec: FlutterStandardMessageCodec { PermissionApiPigeonCodec.shared }
/// Sets up an instance of `PermissionApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
let isIgnoringBatteryOptimizationsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.isIgnoringBatteryOptimizations\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
isIgnoringBatteryOptimizationsChannel.setMessageHandler { _, reply in
do {
let result = try api.isIgnoringBatteryOptimizations()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
isIgnoringBatteryOptimizationsChannel.setMessageHandler(nil)
}
let hasManageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
hasManageMediaPermissionChannel.setMessageHandler { _, reply in
@@ -1,6 +1,10 @@
import Foundation
class PermissionApiImpl: PermissionApi {
func isIgnoringBatteryOptimizations() throws -> PermissionStatus {
return PermissionStatus.granted;
}
func hasManageMediaPermission() throws -> Bool {
return false
}
+58 -42
View File
@@ -526,16 +526,17 @@ class MessagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol NativeSyncApi {
func shouldFullSync() throws -> Bool
func getMediaChanges() throws -> SyncDelta
func shouldFullSync(completion: @escaping (Result<Bool, Error>) -> Void)
func getMediaChanges(completion: @escaping (Result<SyncDelta, Error>) -> Void)
func checkpointSync() throws
func clearSyncCheckpoint() throws
func getAssetIdsForAlbum(albumId: String) throws -> [String]
func getAlbums() throws -> [PlatformAlbum]
func getAssetIdsForAlbum(albumId: String, completion: @escaping (Result<[String], Error>) -> Void)
func getAlbums(completion: @escaping (Result<[PlatformAlbum], Error>) -> Void)
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset]
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?, completion: @escaping (Result<[PlatformAsset], Error>) -> Void)
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
func cancelHashing() throws
func cancelSync() throws
func getTrashedAssets() throws -> [String: [PlatformAsset]]
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void)
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
@@ -555,26 +556,28 @@ class NativeSyncApiSetup {
let shouldFullSyncChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
shouldFullSyncChannel.setMessageHandler { _, reply in
do {
let result = try api.shouldFullSync()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
api.shouldFullSync { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
shouldFullSyncChannel.setMessageHandler(nil)
}
let getMediaChangesChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
let getMediaChangesChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
getMediaChangesChannel.setMessageHandler { _, reply in
do {
let result = try api.getMediaChanges()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
api.getMediaChanges { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
@@ -606,33 +609,33 @@ class NativeSyncApiSetup {
} else {
clearSyncCheckpointChannel.setMessageHandler(nil)
}
let getAssetIdsForAlbumChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
let getAssetIdsForAlbumChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
getAssetIdsForAlbumChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let albumIdArg = args[0] as! String
do {
let result = try api.getAssetIdsForAlbum(albumId: albumIdArg)
reply(wrapResult(result))
} catch {
reply(wrapError(error))
api.getAssetIdsForAlbum(albumId: albumIdArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
getAssetIdsForAlbumChannel.setMessageHandler(nil)
}
let getAlbumsChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
let getAlbumsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
getAlbumsChannel.setMessageHandler { _, reply in
do {
let result = try api.getAlbums()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
api.getAlbums { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
@@ -656,19 +659,19 @@ class NativeSyncApiSetup {
} else {
getAssetsCountSinceChannel.setMessageHandler(nil)
}
let getAssetsForAlbumChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
let getAssetsForAlbumChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
getAssetsForAlbumChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let albumIdArg = args[0] as! String
let updatedTimeCondArg: Int64? = nilOrValue(args[1])
do {
let result = try api.getAssetsForAlbum(albumId: albumIdArg, updatedTimeCond: updatedTimeCondArg)
reply(wrapResult(result))
} catch {
reply(wrapError(error))
api.getAssetsForAlbum(albumId: albumIdArg, updatedTimeCond: updatedTimeCondArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
@@ -707,6 +710,19 @@ class NativeSyncApiSetup {
} else {
cancelHashingChannel.setMessageHandler(nil)
}
let cancelSyncChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelSync\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
cancelSyncChannel.setMessageHandler { _, reply in
do {
try api.cancelSync()
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
cancelSyncChannel.setMessageHandler(nil)
}
let getTrashedAssetsChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
+102 -45
View File
@@ -39,6 +39,9 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
private static let hashCancelledCode = "HASH_CANCELLED"
private static let hashCancelled = Result<[HashResult], Error>.failure(PigeonError(code: hashCancelledCode, message: "Hashing cancelled", details: nil))
private var syncTask: Task<Void?, Error>?
private static let syncCancelledCode = "SYNC_CANCELLED"
private static let syncCancelled = PigeonError(code: syncCancelledCode, message: "Sync cancelled", details: nil)
init(with defaults: UserDefaults = .standard) {
self.defaults = defaults
@@ -71,7 +74,11 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
saveChangeToken(token: PHPhotoLibrary.shared().currentChangeToken)
}
func shouldFullSync() -> Bool {
func shouldFullSync(completion: @escaping (Result<Bool, Error>) -> Void) {
runSync(completion) { $0.shouldFullSync() }
}
private func shouldFullSync() -> Bool {
guard #available(iOS 16, *),
PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized,
let storedToken = getChangeToken() else {
@@ -87,12 +94,17 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
return false
}
func getAlbums() throws -> [PlatformAlbum] {
func getAlbums(completion: @escaping (Result<[PlatformAlbum], Error>) -> Void) {
runSync(completion) { try $0.getAlbums() }
}
private func getAlbums() throws -> [PlatformAlbum] {
var albums: [PlatformAlbum] = []
albumTypes.forEach { type in
for type in albumTypes {
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
for i in 0..<collections.count {
try Task.checkCancellation()
let album = collections.object(at: i)
// Ignore recovered album
@@ -126,7 +138,11 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
return albums.sorted { $0.id < $1.id }
}
func getMediaChanges() throws -> SyncDelta {
func getMediaChanges(completion: @escaping (Result<SyncDelta, Error>) -> Void) {
runSync(completion) { try $0.getMediaChanges() }
}
private func getMediaChanges() throws -> SyncDelta {
guard #available(iOS 16, *) else {
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil)
}
@@ -146,51 +162,49 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
return SyncDelta(hasChanges: false, updates: [], deletes: [], assetAlbums: [:])
}
do {
let changes = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken)
let changes = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken)
var updatedAssets: Set<AssetWrapper> = []
var deletedAssets: Set<String> = []
for change in changes {
try Task.checkCancellation()
guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue }
var updatedAssets: Set<AssetWrapper> = []
var deletedAssets: Set<String> = []
let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers)
deletedAssets.formUnion(details.deletedLocalIdentifiers)
for change in changes {
guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue }
if (updated.isEmpty) { continue }
let options = PHFetchOptions()
options.includeHiddenAssets = false
let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: options)
for i in 0..<result.count {
let asset = result.object(at: i)
let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers)
deletedAssets.formUnion(details.deletedLocalIdentifiers)
if (updated.isEmpty) { continue }
let options = PHFetchOptions()
options.includeHiddenAssets = false
let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: options)
for i in 0..<result.count {
let asset = result.object(at: i)
// Asset wrapper only uses the id for comparison. Multiple change can contain the same asset, skip duplicate changes
let predicate = PlatformAsset(
id: asset.localIdentifier,
name: "",
type: 0,
durationMs: 0,
orientation: 0,
isFavorite: false,
playbackStyle: .unknown
)
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
continue
}
let domainAsset = AssetWrapper(with: asset.toPlatformAsset())
updatedAssets.insert(domainAsset)
// Asset wrapper only uses the id for comparison. Multiple change can contain the same asset, skip duplicate changes
let predicate = PlatformAsset(
id: asset.localIdentifier,
name: "",
type: 0,
durationMs: 0,
orientation: 0,
isFavorite: false,
playbackStyle: .unknown
)
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
continue
}
let domainAsset = AssetWrapper(with: asset.toPlatformAsset())
updatedAssets.insert(domainAsset)
}
let updates = Array(updatedAssets.map { $0.asset })
return SyncDelta(hasChanges: true, updates: updates, deletes: Array(deletedAssets), assetAlbums: buildAssetAlbumsMap(assets: updates))
}
let updates = Array(updatedAssets.map { $0.asset })
return SyncDelta(hasChanges: true, updates: updates, deletes: Array(deletedAssets), assetAlbums: buildAssetAlbumsMap(assets: updates))
}
private func buildAssetAlbumsMap(assets: Array<PlatformAsset>) -> [String: [String]] {
guard !assets.isEmpty else {
return [:]
@@ -213,7 +227,11 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
return albumAssets
}
func getAssetIdsForAlbum(albumId: String) throws -> [String] {
func getAssetIdsForAlbum(albumId: String, completion: @escaping (Result<[String], Error>) -> Void) {
runSync(completion) { try $0.getAssetIdsForAlbum(albumId: albumId) }
}
private func getAssetIdsForAlbum(albumId: String) throws -> [String] {
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
guard let album = collections.firstObject else {
return []
@@ -223,9 +241,14 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
let options = PHFetchOptions()
options.includeHiddenAssets = false
let assets = getAssetsFromAlbum(in: album, options: options)
assets.enumerateObjects { (asset, _, _) in
assets.enumerateObjects { (asset, _, stop) in
if Task.isCancelled {
stop.pointee = true
return
}
ids.append(asset.localIdentifier)
}
try Task.checkCancellation()
return ids
}
@@ -243,7 +266,11 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
return Int64(assets.count)
}
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] {
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?, completion: @escaping (Result<[PlatformAsset], Error>) -> Void) {
runSync(completion) { try $0.getAssetsForAlbum(albumId: albumId, updatedTimeCond: updatedTimeCond) }
}
private func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] {
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
guard let album = collections.firstObject else {
return []
@@ -262,9 +289,14 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
}
var assets: [PlatformAsset] = []
result.enumerateObjects { (asset, _, _) in
result.enumerateObjects { (asset, _, stop) in
if Task.isCancelled {
stop.pointee = true
return
}
assets.append(asset.toPlatformAsset())
}
try Task.checkCancellation()
return assets
}
@@ -324,6 +356,31 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
hashTask = nil
}
func cancelSync() {
syncTask?.cancel()
syncTask = nil
}
private func runSync<T>(
_ completion: @escaping (Result<T, Error>) -> Void,
_ work: @escaping (NativeSyncApiImpl) throws -> T
) {
syncTask?.cancel()
syncTask = Task { [weak self] in
guard let self else { return nil }
let result: Result<T, Error>
do {
result = .success(try work(self))
} catch is CancellationError {
result = .failure(Self.syncCancelled)
} catch {
result = .failure(error)
}
self.completeWhenActive(for: completion, with: result)
return nil
}
}
private func hashAsset(_ asset: PHAsset, allowNetworkAccess: Bool) async -> HashResult? {
class RequestRef {
var id: PHAssetResourceDataRequestID?
+2
View File
@@ -22,3 +22,5 @@ enum AssetDateAggregation { start, end }
enum SlideshowLook { contain, cover, blurredBackground }
enum SlideshowDirection { forward, backward, shuffle }
enum PartnerDirection { sharedBy, sharedWith }
+128
View File
@@ -0,0 +1,128 @@
class Ocr {
final String id;
final String assetId;
final double x1;
final double y1;
final double x2;
final double y2;
final double x3;
final double y3;
final double x4;
final double y4;
final double boxScore;
final double textScore;
final String text;
final bool isVisible;
const Ocr({
required this.id,
required this.assetId,
required this.x1,
required this.y1,
required this.x2,
required this.y2,
required this.x3,
required this.y3,
required this.x4,
required this.y4,
required this.boxScore,
required this.textScore,
required this.text,
required this.isVisible,
});
Ocr copyWith({
String? id,
String? assetId,
double? x1,
double? y1,
double? x2,
double? y2,
double? x3,
double? y3,
double? x4,
double? y4,
double? boxScore,
double? textScore,
String? text,
bool? isVisible,
}) {
return Ocr(
id: id ?? this.id,
assetId: assetId ?? this.assetId,
x1: x1 ?? this.x1,
y1: y1 ?? this.y1,
x2: x2 ?? this.x2,
y2: y2 ?? this.y2,
x3: x3 ?? this.x3,
y3: y3 ?? this.y3,
x4: x4 ?? this.x4,
y4: y4 ?? this.y4,
boxScore: boxScore ?? this.boxScore,
textScore: textScore ?? this.textScore,
text: text ?? this.text,
isVisible: isVisible ?? this.isVisible,
);
}
@override
String toString() {
return '''Ocr {
id: $id,
assetId: $assetId,
x1: $x1,
y1: $y1,
x2: $x2,
y2: $y2,
x3: $x3,
y3: $y3,
x4: $x4,
y4: $y4,
boxScore: $boxScore,
textScore: $textScore,
text: $text,
isVisible: $isVisible
}''';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is Ocr &&
other.id == id &&
other.assetId == assetId &&
other.x1 == x1 &&
other.y1 == y1 &&
other.x2 == x2 &&
other.y2 == y2 &&
other.x3 == x3 &&
other.y3 == y3 &&
other.x4 == x4 &&
other.y4 == y4 &&
other.boxScore == boxScore &&
other.textScore == textScore &&
other.text == text &&
other.isVisible == isVisible;
}
@override
int get hashCode {
return id.hashCode ^
assetId.hashCode ^
x1.hashCode ^
y1.hashCode ^
x2.hashCode ^
y2.hashCode ^
x3.hashCode ^
y3.hashCode ^
x4.hashCode ^
y4.hashCode ^
boxScore.hashCode ^
textScore.hashCode ^
text.hashCode ^
isVisible.hashCode;
}
}
+122
View File
@@ -237,3 +237,125 @@ class PartnerUserDto {
return id.hashCode ^ email.hashCode ^ name.hashCode ^ inTimeline.hashCode ^ profileImagePath.hashCode;
}
}
class User {
final String id;
final String name;
final String email;
final DateTime profileChangedAt;
final bool hasProfileImage;
final AvatarColor? avatarColor;
const User({
required this.id,
required this.name,
required this.email,
required this.profileChangedAt,
required this.hasProfileImage,
this.avatarColor = AvatarColor.primary,
});
@override
String toString() {
return 'User(id: $id, name: $name, email: $email, profileChangedAt: $profileChangedAt, hasProfileImage: $hasProfileImage, avatarColor: $avatarColor)';
}
@override
bool operator ==(covariant User other) {
if (identical(this, other)) {
return true;
}
return other.id == id &&
other.name == name &&
other.email == email &&
other.profileChangedAt == profileChangedAt &&
other.hasProfileImage == hasProfileImage &&
other.avatarColor == avatarColor;
}
@override
int get hashCode => Object.hash(id, name, email, profileChangedAt, hasProfileImage, avatarColor);
}
class AuthUser extends User {
final bool isAdmin;
final String? pinCode;
final int? quotaSizeInBytes;
final int quotaUsageInBytes;
const AuthUser({
required super.id,
required super.name,
required super.email,
required super.profileChangedAt,
required super.hasProfileImage,
super.avatarColor,
this.isAdmin = false,
this.pinCode,
this.quotaSizeInBytes = 0,
this.quotaUsageInBytes = 0,
});
@override
String toString() {
return 'AuthUser(user: ${super.toString()}, isAdmin: $isAdmin, pinCode: $pinCode, quotaSizeInBytes: $quotaSizeInBytes, quotaUsageInBytes: $quotaUsageInBytes)';
}
@override
bool operator ==(covariant AuthUser other) {
if (identical(this, other)) {
return true;
}
return super == other &&
other.isAdmin == isAdmin &&
other.pinCode == pinCode &&
other.quotaSizeInBytes == quotaSizeInBytes &&
other.quotaUsageInBytes == quotaUsageInBytes;
}
@override
int get hashCode => Object.hash(super.hashCode, isAdmin, pinCode, quotaSizeInBytes, quotaUsageInBytes);
}
class Partner extends User {
final bool inTimeline;
const Partner({
required super.id,
required super.name,
required super.email,
required super.profileChangedAt,
required super.hasProfileImage,
super.avatarColor,
this.inTimeline = false,
});
Partner.fromUser(User user, {this.inTimeline = false})
: super(
id: user.id,
name: user.name,
email: user.email,
profileChangedAt: user.profileChangedAt,
hasProfileImage: user.hasProfileImage,
avatarColor: user.avatarColor,
);
@override
String toString() {
return 'Partner(user: ${super.toString()}, inTimeline: $inTimeline)';
}
@override
bool operator ==(covariant Partner other) {
if (identical(this, other)) {
return true;
}
return super == other && other.inTimeline == inTimeline;
}
@override
int get hashCode => Object.hash(super.hashCode, inTimeline);
}
@@ -113,9 +113,35 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
@override
Future<void> onIosUpload(bool isRefresh, int? maxSeconds) async {
final hashTimeout = isRefresh ? const Duration(seconds: 5) : Duration(minutes: _isBackupEnabled ? 3 : 6);
final backupTimeout = maxSeconds != null ? Duration(seconds: maxSeconds - 1) : null;
return _backgroundLoop(hashTimeout: hashTimeout, backupTimeout: backupTimeout, debugLabel: 'iOS background upload');
_logger.info('iOS background upload started with maxSeconds: ${maxSeconds}s');
final sw = Stopwatch()..start();
try {
final budget = maxSeconds != null ? Duration(seconds: maxSeconds - 1) : null;
final sync = _ref?.read(backgroundSyncProvider);
if (sync == null) {
return;
}
// Run sync local, sync remote, hash and backup concurrently so the bg
// refresh task (20s budget) can make progress on all four instead of
// racing them sequentially. Phases are independent at the data layer:
// hash and handle_backup read drift state and tolerate stale reads
// (server-side dedup catches the rare race). The single budget caps the
// whole batch; no phase needs its own timeout.
final all = Future.wait<dynamic>([sync.syncLocal(), sync.syncRemote(), sync.hashAssets(), _handleBackup()]);
if (budget != null) {
await all.timeout(budget, onTimeout: () => <dynamic>[]);
} else {
await all;
}
} catch (error, stack) {
_logger.severe("Failed to complete iOS background upload", error, stack);
} finally {
sw.stop();
_logger.info("iOS background upload completed in ${sw.elapsed.inSeconds}s");
await _cleanup();
}
}
Future<void> _backgroundLoop({
@@ -188,20 +214,14 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
if (!_cancellationToken.isCompleted) {
_cancellationToken.complete();
}
final cleanupFutures = [
nativeSyncApi?.cancelHashing(),
workerManagerPatch.dispose().catchError((_) async {
// Discard any errors on the dispose call
return;
}),
LogService.I.dispose(),
Store.dispose(),
backgroundSyncManager?.cancel(),
_drift.optimize(allTables: true),
];
await Future.wait(cleanupFutures.nonNulls);
// Workers share one sqlite connection, so DB teardown must wait until every worker has stopped using it.
await Future.wait([
if (backgroundSyncManager != null) backgroundSyncManager.cancel(),
if (nativeSyncApi != null) nativeSyncApi.cancelHashing(),
]);
await workerManagerPatch.dispose().catchError((_) async {});
await Future.wait([LogService.I.dispose(), Store.dispose(), _drift.optimize(allTables: true)]);
await _drift.close();
await _driftLogger.close();
+10 -4
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
@@ -17,7 +19,7 @@ class HashService {
final DriftLocalAssetRepository _localAssetRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final NativeSyncApi _nativeSyncApi;
final bool Function()? _cancelChecker;
final Completer<void>? _cancellation;
final _log = Logger('HashService');
HashService({
@@ -25,11 +27,15 @@ class HashService {
required this._localAssetRepository,
required this._trashedLocalAssetRepository,
required this._nativeSyncApi,
this._cancelChecker,
this._cancellation,
int? batchSize,
}) : _batchSize = batchSize ?? kBatchHashFileLimit;
}) : _batchSize = batchSize ?? kBatchHashFileLimit {
// Stop the in-flight native hash call promptly on cancellation; the loops
// below also observe [isCancelled] to bail between batches.
_cancellation?.future.then((_) => _nativeSyncApi.cancelHashing().onError(_log.warning));
}
bool get isCancelled => _cancelChecker?.call() ?? false;
bool get isCancelled => _cancellation?.isCompleted ?? false;
Future<void> hashAssets() async {
_log.info("Starting hashing of assets");
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.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/store.model.dart';
@@ -17,6 +18,8 @@ import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:logging/logging.dart';
const String _kSyncCancelledCode = "SYNC_CANCELLED";
class LocalSyncService {
final DriftLocalAlbumRepository _localAlbumRepository;
// ignore: unused_field
@@ -25,6 +28,7 @@ class LocalSyncService {
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final AssetMediaRepository _assetMediaRepository;
final IPermissionRepository _permissionRepository;
final Completer<void>? _cancellation;
final Logger _log = Logger("DeviceSyncService");
LocalSyncService({
@@ -34,7 +38,12 @@ class LocalSyncService {
required this._trashedLocalAssetRepository,
required this._assetMediaRepository,
required this._permissionRepository,
});
this._cancellation,
}) {
_cancellation?.future.then((_) => _nativeSyncApi.cancelSync().onError(_log.warning));
}
bool get _isCancelled => _cancellation?.isCompleted ?? false;
Future<void> sync({bool full = false}) async {
final Stopwatch stopwatch = Stopwatch()..start();
@@ -81,6 +90,10 @@ class LocalSyncService {
// detect album deletions from the native side
if (CurrentPlatform.isAndroid) {
for (final album in dbAlbums) {
if (_isCancelled) {
_log.warning("Local sync cancelled. Stopped processing albums.");
return;
}
final deviceIds = await _nativeSyncApi.getAssetIdsForAlbum(album.id);
await _localAlbumRepository.syncDeletes(album.id, deviceIds);
}
@@ -91,6 +104,10 @@ class LocalSyncService {
// does not include changes for cloud albums.
final cloudAlbums = deviceAlbums.where((a) => a.isCloud).toLocalAlbums();
for (final album in cloudAlbums) {
if (_isCancelled) {
_log.warning("Local sync cancelled. Stopped processing cloud albums.");
return;
}
final dbAlbum = dbAlbums.firstWhereOrNull((a) => a.id == album.id);
if (dbAlbum == null) {
_log.warning("Cloud album ${album.name} not found in local database. Skipping sync.");
@@ -102,6 +119,12 @@ class LocalSyncService {
await _mapIosCloudIds(newAssets);
}
await _nativeSyncApi.checkpointSync();
} on PlatformException catch (e, s) {
if (e.code == _kSyncCancelledCode) {
_log.warning("Local sync cancelled");
} else {
_log.severe("Error performing device sync", e, s);
}
} catch (e, s) {
_log.severe("Error performing device sync", e, s);
} finally {
@@ -129,12 +152,21 @@ class LocalSyncService {
await _nativeSyncApi.checkpointSync();
stopwatch.stop();
_log.info("Full device sync took - ${stopwatch.elapsedMilliseconds}ms");
} on PlatformException catch (e, s) {
if (e.code == _kSyncCancelledCode) {
_log.warning("Full device sync cancelled");
} else {
_log.severe("Error performing full device sync", e, s);
}
} catch (e, s) {
_log.severe("Error performing full device sync", e, s);
}
}
Future<void> addAlbum(LocalAlbum album) async {
if (_isCancelled) {
return;
}
try {
_log.fine("Adding device album ${album.name}");
@@ -162,6 +194,9 @@ class LocalSyncService {
// The deviceAlbum is ignored since we are going to refresh it anyways
FutureOr<bool> updateAlbum(LocalAlbum dbAlbum, LocalAlbum deviceAlbum) async {
if (_isCancelled) {
return false;
}
try {
_log.fine("Syncing device album ${dbAlbum.name}");
+9 -3
View File
@@ -112,10 +112,16 @@ class LogService {
return _flushBuffer();
}
Future<void> dispose() {
Future<void> dispose() async {
_flushTimer?.cancel();
_logSubscription.cancel();
return _flushBuffer();
_flushTimer = null;
await _logSubscription.cancel();
await _flushBuffer();
// Allow a subsequent init() (e.g. when a worker isolate is reused) to
// create a fresh instance instead of returning this disposed one.
if (identical(_instance, this)) {
_instance = null;
}
}
Future<void> _flushBuffer() async {
@@ -0,0 +1,12 @@
import 'package:immich_mobile/domain/models/ocr.model.dart';
import 'package:immich_mobile/infrastructure/repositories/ocr.repository.dart';
class OcrService {
final OcrRepository _repository;
const OcrService(this._repository);
Future<List<Ocr>?> get(String assetId) {
return _repository.get(assetId);
}
}
+27 -36
View File
@@ -1,51 +1,42 @@
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/repositories/partner_api.repository.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:stream_transform/stream_transform.dart';
class DriftPartnerService {
final DriftPartnerRepository _driftPartnerRepository;
class PartnerService {
final UserRepository _userRepository;
final PartnerRepository _partnerRepository;
final PartnerApiRepository _partnerApiRepository;
const DriftPartnerService(this._driftPartnerRepository, this._partnerApiRepository);
const PartnerService(this._userRepository, this._partnerRepository, this._partnerApiRepository);
Future<List<PartnerUserDto>> getSharedWith(String userId) {
return _driftPartnerRepository.getSharedWith(userId);
Stream<Iterable<User>> getCandidates(String userId) {
final userStream = _userRepository.getAll();
final partnerStream = _partnerRepository.search(userId, .sharedBy);
return userStream.combineLatest(partnerStream, (users, partners) {
final partnersSet = partners.map((partner) => partner.id).toSet();
return users.where((user) => user.id != userId && !partnersSet.contains(user.id));
});
}
Future<List<PartnerUserDto>> getSharedBy(String userId) {
return _driftPartnerRepository.getSharedBy(userId);
Stream<Iterable<Partner>> search(String userId, PartnerDirection direction) =>
_partnerRepository.search(userId, direction);
Future<void> update({required String sharedById, required String sharedWithId, required bool inTimeline}) async {
await _partnerApiRepository.update(sharedById, inTimeline: inTimeline);
await _partnerRepository.update(sharedById: sharedById, sharedWithId: sharedWithId, inTimeline: inTimeline);
}
Future<List<PartnerUserDto>> getAvailablePartners(String currentUserId) async {
final otherUsers = await _driftPartnerRepository.getAvailablePartners(currentUserId);
final currentPartners = await _driftPartnerRepository.getSharedBy(currentUserId);
final available = otherUsers.where((user) {
return !currentPartners.any((partner) => partner.id == user.id);
}).toList();
return available;
Future<void> create({required String sharedById, required String sharedWithId, bool inTimeline = false}) async {
await _partnerApiRepository.create(sharedWithId);
await _partnerRepository.create(sharedById: sharedById, sharedWithId: sharedWithId, inTimeline: inTimeline);
}
Future<void> toggleShowInTimeline(String partnerId, String userId) async {
final partner = await _driftPartnerRepository.getPartner(partnerId, userId);
if (partner == null) {
dPrint(() => "Partner not found: $partnerId for user: $userId");
return;
}
await _partnerApiRepository.update(partnerId, inTimeline: !partner.inTimeline);
await _driftPartnerRepository.toggleShowInTimeline(partner, userId);
}
Future<void> addPartner(String partnerId, String userId) async {
await _partnerApiRepository.create(partnerId);
await _driftPartnerRepository.create(partnerId, userId);
}
Future<void> removePartner(String partnerId, String userId) async {
await _partnerApiRepository.delete(partnerId);
await _driftPartnerRepository.delete(partnerId, userId);
Future<void> delete({required String sharedById, required String sharedWithId}) async {
await _partnerApiRepository.delete(sharedWithId);
await _partnerRepository.delete(sharedById: sharedById, sharedWithId: sharedWithId);
}
}
@@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:logging/logging.dart';
@@ -137,7 +138,7 @@ class RemoteAlbumService {
Future<RemoteAlbum> updateAlbum(
String albumId, {
String? name,
String? description,
Option<String?> description = const Option.none(),
String? thumbnailAssetId,
bool? isActivityEnabled,
AlbumAssetOrder? order,
@@ -54,7 +54,13 @@ class StoreService {
/// Disposes the store and cancels the subscription. To reuse the store call init() again
Future<void> dispose() async {
await _storeUpdateSubscription?.cancel();
_storeUpdateSubscription = null;
_cache.clear();
// Allow a subsequent init() (e.g. when a worker isolate is reused) to
// create a fresh instance instead of returning this disposed one.
if (identical(_instance, this)) {
_instance = null;
}
}
/// Returns the cached value for [key], or `null`
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
@@ -5,6 +7,7 @@ import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/utils/debug_print.dart';
@@ -16,6 +19,7 @@ final syncLinkedAlbumServiceProvider = Provider(
ref.watch(remoteAlbumRepository),
ref.watch(driftAlbumApiRepositoryProvider),
ref.watch(storeServiceProvider),
cancellation: ref.watch(cancellationProvider),
),
);
@@ -24,13 +28,15 @@ class SyncLinkedAlbumService {
final DriftRemoteAlbumRepository _remoteAlbumRepository;
final DriftAlbumApiRepository _albumApiRepository;
final StoreService _storeService;
final Completer<void>? _cancellation;
SyncLinkedAlbumService(
this._localAlbumRepository,
this._remoteAlbumRepository,
this._albumApiRepository,
this._storeService,
);
this._storeService, {
this._cancellation,
});
final _log = Logger("SyncLinkedAlbumService");
@@ -55,7 +61,11 @@ class SyncLinkedAlbumService {
final assetIds = await _remoteAlbumRepository.getLinkedAssetIds(userId, localAlbum.id, linkedRemoteAlbumId);
_log.fine("Syncing ${assetIds.length} assets to remote album: ${remoteAlbum.name}");
if (assetIds.isNotEmpty) {
final album = await _albumApiRepository.addAssets(remoteAlbum.id, assetIds);
final album = await _albumApiRepository.addAssets(
remoteAlbum.id,
assetIds,
abortTrigger: _cancellation?.future,
);
await _remoteAlbumRepository.addAssets(remoteAlbum.id, album.added);
}
}),
@@ -38,7 +38,7 @@ class SyncStreamService {
final IPermissionRepository _permissionRepository;
final SyncMigrationRepository _syncMigrationRepository;
final ApiService _api;
final bool Function()? _cancelChecker;
final Completer<void>? _cancellation;
SyncStreamService({
required this._syncApiRepository,
@@ -49,10 +49,10 @@ class SyncStreamService {
required this._permissionRepository,
required this._syncMigrationRepository,
required this._api,
this._cancelChecker,
this._cancellation,
});
bool get isCancelled => _cancelChecker?.call() ?? false;
bool get isCancelled => _cancellation?.isCompleted ?? false;
Future<bool> sync() async {
_logger.info("Remote sync request for user");
@@ -80,10 +80,15 @@ class SyncStreamService {
_handleEvents,
serverVersion: serverSemVer,
onReset: () => shouldReset = true,
abortSignal: _cancellation?.future,
);
if (shouldReset) {
_logger.info("Resetting sync state as requested by server");
await _syncApiRepository.streamChanges(_handleEvents, serverVersion: serverSemVer);
await _syncApiRepository.streamChanges(
_handleEvents,
serverVersion: serverSemVer,
abortSignal: _cancellation?.future,
);
}
previousLength = migrations.length;
@@ -312,13 +317,17 @@ class SyncStreamService {
return _syncStreamRepository.updateAssetFacesV2(data.cast());
case SyncEntityType.assetFaceDeleteV1:
return _syncStreamRepository.deleteAssetFacesV1(data.cast());
case SyncEntityType.assetOcrV1:
return _syncStreamRepository.updateAssetOcrV1(data.cast());
case SyncEntityType.assetOcrDeleteV1:
return _syncStreamRepository.deleteAssetOcrV1(data.cast());
default:
_logger.warning("Unknown sync data type: $type");
}
}
Future<void> handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) async {
if (batchData.isEmpty) {
if (batchData.isEmpty || isCancelled) {
return;
}
@@ -361,7 +370,7 @@ class SyncStreamService {
}
Future<void> handleWsAssetUploadReadyV2Batch(List<dynamic> batchData) async {
if (batchData.isEmpty) {
if (batchData.isEmpty || isCancelled) {
return;
}
@@ -404,6 +413,9 @@ class SyncStreamService {
}
Future<void> handleWsAssetEditReadyV1(dynamic data) async {
if (isCancelled) {
return;
}
_logger.info('Processing AssetEditReadyV1 event');
try {
@@ -444,6 +456,9 @@ class SyncStreamService {
}
Future<void> handleWsAssetEditReadyV2(dynamic data) async {
if (isCancelled) {
return;
}
_logger.info('Processing AssetEditReadyV2 event');
try {
+15 -41
View File
@@ -50,53 +50,27 @@ class BackgroundSyncManager {
});
Future<void> cancel() async {
final futures = <Future>[];
if (_syncTask != null) {
futures.add(_syncTask!.future);
final tasks = [
_syncTask,
_syncWebsocketTask,
_cloudIdSyncTask,
_linkedAlbumSyncTask,
_deviceAlbumSyncTask,
_hashTask,
];
final futures = [
for (final task in tasks)
if (task != null) task.future,
];
for (final task in tasks) {
task?.cancel();
}
_syncTask?.cancel();
_syncTask = null;
if (_syncWebsocketTask != null) {
futures.add(_syncWebsocketTask!.future);
}
_syncWebsocketTask?.cancel();
_syncWebsocketTask = null;
if (_cloudIdSyncTask != null) {
futures.add(_cloudIdSyncTask!.future);
}
_cloudIdSyncTask?.cancel();
_cloudIdSyncTask = null;
if (_linkedAlbumSyncTask != null) {
futures.add(_linkedAlbumSyncTask!.future);
}
_linkedAlbumSyncTask?.cancel();
_linkedAlbumSyncTask = null;
try {
await Future.wait(futures);
} on CanceledError {
// Ignore cancellation errors
}
}
Future<void> cancelLocal() async {
final futures = <Future>[];
if (_hashTask != null) {
futures.add(_hashTask!.future);
}
_hashTask?.cancel();
_hashTask = null;
if (_deviceAlbumSyncTask != null) {
futures.add(_deviceAlbumSyncTask!.future);
}
_deviceAlbumSyncTask?.cancel();
_deviceAlbumSyncTask = null;
_hashTask = null;
try {
await Future.wait(futures);
+31 -7
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:drift/drift.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
@@ -9,6 +11,7 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
@@ -51,9 +54,10 @@ Future<void> syncCloudIds(ProviderContainer ref) async {
}
final assetApi = ref.read(apiServiceProvider).assetsApi;
final cancellation = ref.read(cancellationProvider);
// Process cloud IDs in paginated batches
await _processCloudIdMappingsInBatches(db, currentUser.id, assetApi, canBulkUpdateMetadata, logger);
await _processCloudIdMappingsInBatches(db, currentUser.id, assetApi, canBulkUpdateMetadata, logger, cancellation);
}
Future<void> _processCloudIdMappingsInBatches(
@@ -62,12 +66,17 @@ Future<void> _processCloudIdMappingsInBatches(
AssetsApi assetsApi,
bool canBulkUpdate,
Logger logger,
Completer<void> cancellation,
) async {
const pageSize = 20000;
String? lastLocalId;
final seenRemoteAssetIds = <String>{};
while (true) {
if (cancellation.isCompleted) {
logger.warning('Cloud ID migration cancelled. Stopping batch processing.');
break;
}
final mappings = await _fetchCloudIdMappings(drift, userId, pageSize, lastLocalId);
if (mappings.isEmpty) {
break;
@@ -98,9 +107,9 @@ Future<void> _processCloudIdMappingsInBatches(
if (items.isNotEmpty) {
if (canBulkUpdate) {
await _bulkUpdateCloudIds(assetsApi, items);
await _bulkUpdateCloudIds(assetsApi, items, cancellation.future);
} else {
await _sequentialUpdateCloudIds(assetsApi, items);
await _sequentialUpdateCloudIds(assetsApi, items, cancellation);
}
}
@@ -111,20 +120,35 @@ Future<void> _processCloudIdMappingsInBatches(
}
}
Future<void> _sequentialUpdateCloudIds(AssetsApi assetsApi, List<AssetMetadataBulkUpsertItemDto> items) async {
Future<void> _sequentialUpdateCloudIds(
AssetsApi assetsApi,
List<AssetMetadataBulkUpsertItemDto> items,
Completer<void> cancellation,
) async {
for (final item in items) {
if (cancellation.isCompleted) {
break;
}
final upsertItem = AssetMetadataUpsertItemDto(key: item.key, value: item.value);
try {
await assetsApi.updateAssetMetadata(item.assetId, AssetMetadataUpsertDto(items: [upsertItem]));
await assetsApi.updateAssetMetadata(
item.assetId,
AssetMetadataUpsertDto(items: [upsertItem]),
abortTrigger: cancellation.future,
);
} catch (error, stack) {
Logger('migrateCloudIds').warning('Failed to update metadata for asset ${item.assetId}', error, stack);
}
}
}
Future<void> _bulkUpdateCloudIds(AssetsApi assetsApi, List<AssetMetadataBulkUpsertItemDto> items) async {
Future<void> _bulkUpdateCloudIds(
AssetsApi assetsApi,
List<AssetMetadataBulkUpsertItemDto> items,
Future<void> abortTrigger,
) async {
try {
await assetsApi.updateBulkAssetMetadata(AssetMetadataBulkUpsertDto(items: items));
await assetsApi.updateBulkAssetMetadata(AssetMetadataBulkUpsertDto(items: items), abortTrigger: abortTrigger);
} catch (error, stack) {
Logger('migrateCloudIds').warning('Failed to bulk update metadata', error, stack);
}
+5 -5
View File
@@ -18,11 +18,11 @@ extension DTOToAsset on api.AssetResponseDto {
height: height?.toInt(),
width: width?.toInt(),
isFavorite: isFavorite,
livePhotoVideoId: livePhotoVideoId,
livePhotoVideoId: livePhotoVideoId.orElse(null),
thumbHash: thumbhash,
localId: null,
type: type.toAssetType(),
stackId: stack?.id,
stackId: stack.orElse(null)?.id,
isEdited: isEdited,
);
}
@@ -41,13 +41,13 @@ extension DTOToAsset on api.AssetResponseDto {
height: height?.toInt(),
width: width?.toInt(),
isFavorite: isFavorite,
livePhotoVideoId: livePhotoVideoId,
livePhotoVideoId: livePhotoVideoId.orElse(null),
thumbHash: thumbhash,
localId: null,
type: type.toAssetType(),
stackId: stack?.id,
stackId: stack.orElse(null)?.id,
isEdited: isEdited,
exifInfo: exifInfo != null ? ExifDtoConverter.fromDto(exifInfo!) : const ExifInfo(),
exifInfo: exifInfo.orElse(null) != null ? ExifDtoConverter.fromDto(exifInfo.orElse(null)!) : const ExifInfo(),
);
}
}
@@ -0,0 +1,34 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_asset_ocr_asset_id ON asset_ocr_entity (asset_id)')
class AssetOcrEntity extends Table with DriftDefaultsMixin {
const AssetOcrEntity();
TextColumn get id => text()();
TextColumn get assetId => text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)();
RealColumn get x1 => real()();
RealColumn get y1 => real()();
RealColumn get x2 => real()();
RealColumn get y2 => real()();
RealColumn get x3 => real()();
RealColumn get y3 => real()();
RealColumn get x4 => real()();
RealColumn get y4 => real()();
RealColumn get boxScore => real()();
RealColumn get textScore => real()();
TextColumn get recognizedText => text()();
BoolColumn get isVisible => boolean().withDefault(const Constant(true))();
@override
Set<Column> get primaryKey => {id};
}
File diff suppressed because it is too large Load Diff
@@ -6,6 +6,7 @@ import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)')
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)')
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)')
class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
const LocalAssetEntity();
@@ -1348,3 +1348,7 @@ i0.Index get idxLocalAssetCloudId => i0.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
i0.Index get idxLocalAssetCreatedAt => i0.Index(
'idx_local_asset_created_at',
'CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)',
);
+15
View File
@@ -0,0 +1,15 @@
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
User mapToUser(UserEntityData data) => User(
id: data.id,
name: data.name,
email: data.email,
hasProfileImage: data.hasProfileImage,
profileChangedAt: data.profileChangedAt,
avatarColor: data.avatarColor,
);
Partner mapToPartner(UserEntityData user, PartnerEntityData partner) =>
Partner.fromUser(mapToUser(user), inTimeline: partner.inTimeline);
@@ -1,10 +1,12 @@
import 'dart:async';
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:drift_sqlite_async/drift_sqlite_async.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart';
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart';
import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.dart';
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
@@ -13,7 +15,6 @@ import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/memory.entity.dart';
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/settings.entity.dart';
import 'package:immich_mobile/infrastructure/entities/partner.entity.dart';
import 'package:immich_mobile/infrastructure/entities/person.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart';
@@ -22,6 +23,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.d
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.dart';
import 'package:immich_mobile/infrastructure/entities/settings.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/trashed_local_asset.entity.dart';
@@ -31,6 +33,11 @@ import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart'
import 'package:immich_mobile/infrastructure/repositories/db.repository.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sqlite3/sqlite3.dart';
import 'package:sqlite_async/native.dart';
import 'package:sqlite_async/sqlite_async.dart';
@DriftDatabase(
tables: [
@@ -56,12 +63,14 @@ import 'package:logging/logging.dart';
TrashedLocalAssetEntity,
AssetEditEntity,
SettingsEntity,
AssetOcrEntity,
],
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
)
class Drift extends $Drift {
Drift([QueryExecutor? executor])
: super(executor ?? driftDatabase(name: 'immich', native: const DriftNativeOptions(shareAcrossIsolates: true)));
Drift(super.executor);
Drift.sqlite(SqliteConnection db) : super(SqliteAsyncDriftConnection(db));
Future<void> reset() async {
// https://github.com/simolus3/drift/commit/bd80a46264b6dd833ef4fd87fffc03f5a832ab41#diff-3f879e03b4a35779344ef16170b9353608dd9c42385f5402ec6035aac4dd8a04R76-R94
@@ -98,7 +107,7 @@ class Drift extends $Drift {
}
@override
int get schemaVersion => 27;
int get schemaVersion => 29;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -279,6 +288,13 @@ class Drift extends $Drift {
from26To27: (m, v27) async {
await customStatement('ALTER TABLE metadata RENAME TO settings');
},
from27To28: (m, v28) async {
await m.createIndex(v28.idxLocalAssetCreatedAt);
},
from28To29: (m, v29) async {
await m.createTable(v29.assetOcrEntity);
await m.createIndex(v29.idxAssetOcrAssetId);
},
),
);
@@ -308,3 +324,41 @@ class DriftDatabaseRepository {
Future<T> transaction<T>(Future<T> Function() callback) => _db.transaction(callback);
}
Future<SqliteConnection> openSqliteConnection({required String name}) async {
final dbFolder = await getApplicationDocumentsDirectory();
final file = File(p.join(dbFolder.path, '$name.sqlite'));
return SqliteDatabase.withFactory(
_ImmichSqliteOpenFactory(
path: file.path,
sqliteOptions: const SqliteOptions(
journalMode: SqliteJournalMode.wal, // PRAGMA journal_mode (writer only)
synchronous: SqliteSynchronous.normal, // PRAGMA synchronous
lockTimeout: Duration(seconds: 30), // -> PRAGMA busy_timeout = 30000
),
),
);
}
final class _ImmichSqliteOpenFactory extends NativeSqliteOpenFactory {
_ImmichSqliteOpenFactory({required super.path, super.sqliteOptions});
@override
List<String> pragmaStatements(SqliteOpenOptions options) {
return [
...super.pragmaStatements(options),
'PRAGMA cache_size = -32000', // 32MB
'PRAGMA temp_store = MEMORY',
'PRAGMA foreign_keys = ON',
];
}
}
Future<void> configureSqliteCache() async {
// Make sqlite3 pick a more suitable location for temporary files - the
// one from the system may be inaccessible due to sand-boxing.
final cacheBase = (await getTemporaryDirectory()).path;
// We can't access /tmp on Android, which sqlite3 would try by default.
// Explicitly tell it about the correct temporary directory.
sqlite3.tempDirectory = cacheBase;
}
@@ -45,9 +45,11 @@ import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.da
as i21;
import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart'
as i22;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.drift.dart'
as i23;
import 'package:drift/internal/modular.dart' as i24;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
as i24;
import 'package:drift/internal/modular.dart' as i25;
abstract class $Drift extends i0.GeneratedDatabase {
$Drift(i0.QueryExecutor e) : super(e);
@@ -94,9 +96,12 @@ abstract class $Drift extends i0.GeneratedDatabase {
late final i22.$SettingsEntityTable settingsEntity = i22.$SettingsEntityTable(
this,
);
i23.MergedAssetDrift get mergedAssetDrift => i24.ReadDatabaseContainer(
late final i23.$AssetOcrEntityTable assetOcrEntity = i23.$AssetOcrEntityTable(
this,
).accessor<i23.MergedAssetDrift>(i23.MergedAssetDrift.new);
);
i24.MergedAssetDrift get mergedAssetDrift => i25.ReadDatabaseContainer(
this,
).accessor<i24.MergedAssetDrift>(i24.MergedAssetDrift.new);
@override
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
@@ -112,6 +117,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
i7.idxLocalAlbumAssetAlbumAsset,
i4.idxLocalAssetChecksum,
i4.idxLocalAssetCloudId,
i4.idxLocalAssetCreatedAt,
i3.idxStackPrimaryAssetId,
i2.uQRemoteAssetsOwnerChecksum,
i2.uQRemoteAssetsOwnerLibraryChecksum,
@@ -133,6 +139,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
trashedLocalAssetEntity,
assetEditEntity,
settingsEntity,
assetOcrEntity,
i10.idxPartnerSharedWithId,
i11.idxLatLng,
i11.idxRemoteExifCity,
@@ -145,6 +152,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
i20.idxTrashedLocalAssetChecksum,
i20.idxTrashedLocalAssetAlbum,
i21.idxAssetEditAssetId,
i23.idxAssetOcrAssetId,
];
@override
i0.StreamQueryUpdateRules
@@ -334,6 +342,13 @@ abstract class $Drift extends i0.GeneratedDatabase {
),
result: [i0.TableUpdate('asset_edit_entity', kind: i0.UpdateKind.delete)],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'remote_asset_entity',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [i0.TableUpdate('asset_ocr_entity', kind: i0.UpdateKind.delete)],
),
]);
@override
i0.DriftDatabaseOptions get options =>
@@ -397,4 +412,6 @@ class $DriftManager {
i21.$$AssetEditEntityTableTableManager(_db, _db.assetEditEntity);
i22.$$SettingsEntityTableTableManager get settingsEntity =>
i22.$$SettingsEntityTableTableManager(_db, _db.settingsEntity);
i23.$$AssetOcrEntityTableTableManager get assetOcrEntity =>
i23.$$AssetOcrEntityTableTableManager(_db, _db.assetOcrEntity);
}
File diff suppressed because it is too large Load Diff
@@ -241,7 +241,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
])
..where(_db.localAlbumAssetEntity.albumId.equals(albumId) & _db.localAssetEntity.checksum.isNull())
..orderBy([OrderingTerm.asc(_db.localAssetEntity.id)]);
..orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)]);
return query.map((row) => row.readTable(_db.localAssetEntity).toDto()).get();
}
@@ -1,14 +1,14 @@
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:drift_sqlite_async/drift_sqlite_async.dart';
import 'package:immich_mobile/infrastructure/entities/log.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.drift.dart';
import 'package:sqlite_async/sqlite_async.dart';
@DriftDatabase(tables: [LogMessageEntity])
class DriftLogger extends $DriftLogger {
DriftLogger([QueryExecutor? executor])
: super(
executor ?? driftDatabase(name: 'immich_logs', native: const DriftNativeOptions(shareAcrossIsolates: true)),
);
DriftLogger.fromExecutor(super.executor);
DriftLogger.sqlite(SqliteConnection db) : super(SqliteAsyncDriftConnection(db));
@override
int get schemaVersion => 1;
@@ -19,7 +19,8 @@ class DriftLogger extends $DriftLogger {
await customStatement('PRAGMA foreign_keys = ON');
await customStatement('PRAGMA synchronous = NORMAL');
await customStatement('PRAGMA journal_mode = WAL');
await customStatement('PRAGMA busy_timeout = 500');
await customStatement('PRAGMA busy_timeout = 30000'); // 30s
await customStatement('PRAGMA cache_size = -32000'); // 32MB
await customStatement('PRAGMA temp_store = MEMORY');
},
);
@@ -0,0 +1,38 @@
import 'package:immich_mobile/domain/models/ocr.model.dart';
import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:drift/drift.dart';
class OcrRepository extends DriftDatabaseRepository {
final Drift _db;
const OcrRepository(this._db) : super(_db);
Future<List<Ocr>> get(String assetId) async {
final query = _db.select(_db.assetOcrEntity)
..where((row) => row.assetId.equals(assetId) & row.isVisible.equals(true));
final result = await query.get();
return result.map((e) => e.toDto()).toList();
}
}
extension on AssetOcrEntityData {
Ocr toDto() {
return Ocr(
id: id,
assetId: assetId,
x1: x1,
y1: y1,
x2: x2,
y2: y2,
x3: x3,
y3: y3,
x4: x4,
y4: y4,
boxScore: boxScore,
textScore: textScore,
text: recognizedText,
isVisible: isVisible,
);
}
}
@@ -1,106 +1,62 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart';
import 'package:immich_mobile/infrastructure/mapper.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class DriftPartnerRepository extends DriftDatabaseRepository {
class PartnerRepository {
final Drift _db;
const DriftPartnerRepository(this._db) : super(_db);
const PartnerRepository(this._db);
Future<List<PartnerUserDto>> getPartners(String userId) {
final query = _db.select(_db.partnerEntity).join([
innerJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.partnerEntity.sharedById)),
])..where(_db.partnerEntity.sharedWithId.equals(userId));
Future<Partner> get({required String sharedById, required String sharedWithId}) =>
(_db.select(_db.partnerEntity).join([
innerJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.partnerEntity.sharedById)),
])..where(
_db.partnerEntity.sharedById.equals(sharedById) & _db.partnerEntity.sharedWithId.equals(sharedWithId),
))
.map(_resultToPartner)
.getSingle();
return query.map((row) {
final user = row.readTable(_db.userEntity);
final partner = row.readTable(_db.partnerEntity);
return PartnerUserDto(id: user.id, email: user.email, name: user.name, inTimeline: partner.inTimeline);
}).get();
}
Stream<Iterable<Partner>> search(String userId, PartnerDirection direction) =>
(_db.select(_db.partnerEntity).join([
innerJoin(
_db.userEntity,
_db.userEntity.id.equalsExp(switch (direction) {
.sharedBy => _db.partnerEntity.sharedWithId,
.sharedWith => _db.partnerEntity.sharedById,
}),
),
])..where(
switch (direction) {
.sharedBy => _db.partnerEntity.sharedById,
.sharedWith => _db.partnerEntity.sharedWithId,
}.equals(userId) &
_db.userEntity.id.equals(userId).not(),
))
.map(_resultToPartner)
.watch();
// Get users who we can share our library with
Future<List<PartnerUserDto>> getAvailablePartners(String currentUserId) {
final query = _db.select(_db.userEntity)..where((row) => row.id.equals(currentUserId).not());
Future<void> create({required String sharedById, required String sharedWithId, bool inTimeline = false}) =>
_db.partnerEntity.insertOnConflictUpdate(
PartnerEntityCompanion(
sharedById: Value(sharedById),
sharedWithId: Value(sharedWithId),
inTimeline: Value(inTimeline),
),
);
return query.map((user) {
return PartnerUserDto(id: user.id, email: user.email, name: user.name, inTimeline: false);
}).get();
}
Future<void> update({required String sharedById, required String sharedWithId, required bool inTimeline}) =>
(_db.partnerEntity.update()..where((t) => t.sharedById.equals(sharedById) & t.sharedWithId.equals(sharedWithId)))
.write(PartnerEntityCompanion(inTimeline: Value(inTimeline)));
// Get users who are sharing their photos WITH the current user
Future<List<PartnerUserDto>> getSharedWith(String partnerId) {
final query = _db.select(_db.partnerEntity).join([
innerJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.partnerEntity.sharedById)),
])..where(_db.partnerEntity.sharedWithId.equals(partnerId));
Future<void> delete({required String sharedById, required String sharedWithId}) =>
(_db.partnerEntity.delete()..where((t) => t.sharedById.equals(sharedById) & t.sharedWithId.equals(sharedWithId)))
.go();
return query.map((row) {
final user = row.readTable(_db.userEntity);
final partner = row.readTable(_db.partnerEntity);
return PartnerUserDto(id: user.id, email: user.email, name: user.name, inTimeline: partner.inTimeline);
}).get();
}
// Get users who the current user is sharing their photos TO
Future<List<PartnerUserDto>> getSharedBy(String userId) {
final query = _db.select(_db.partnerEntity).join([
innerJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.partnerEntity.sharedWithId)),
])..where(_db.partnerEntity.sharedById.equals(userId));
return query.map((row) {
final user = row.readTable(_db.userEntity);
final partner = row.readTable(_db.partnerEntity);
return PartnerUserDto(id: user.id, email: user.email, name: user.name, inTimeline: partner.inTimeline);
}).get();
}
Future<List<String>> getAllPartnerIds(String userId) async {
// Get users who are sharing with me (sharedWithId = userId)
final sharingWithMeQuery = _db.select(_db.partnerEntity)..where((tbl) => tbl.sharedWithId.equals(userId));
final sharingWithMe = await sharingWithMeQuery.map((row) => row.sharedById).get();
// Get users who I am sharing with (sharedById = userId)
final sharingWithThemQuery = _db.select(_db.partnerEntity)..where((tbl) => tbl.sharedById.equals(userId));
final sharingWithThem = await sharingWithThemQuery.map((row) => row.sharedWithId).get();
// Combine both lists and remove duplicates
final allPartnerIds = <String>{...sharingWithMe, ...sharingWithThem}.toList();
return allPartnerIds;
}
Future<PartnerUserDto?> getPartner(String partnerId, String userId) {
final query = _db.select(_db.partnerEntity).join([
innerJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.partnerEntity.sharedById)),
])..where(_db.partnerEntity.sharedById.equals(partnerId) & _db.partnerEntity.sharedWithId.equals(userId));
return query.map((row) {
final user = row.readTable(_db.userEntity);
final partner = row.readTable(_db.partnerEntity);
return PartnerUserDto(id: user.id, email: user.email, name: user.name, inTimeline: partner.inTimeline);
}).getSingleOrNull();
}
Future<bool> toggleShowInTimeline(PartnerUserDto partner, String userId) {
return _db.partnerEntity.update().replace(
PartnerEntityCompanion(
sharedById: Value(partner.id),
sharedWithId: Value(userId),
inTimeline: Value(!partner.inTimeline),
),
);
}
Future<int> create(String partnerId, String userId) {
final entity = PartnerEntityCompanion(
sharedById: Value(userId),
sharedWithId: Value(partnerId),
inTimeline: const Value(false),
);
return _db.partnerEntity.insertOne(entity);
}
Future<void> delete(String partnerId, String userId) {
return _db.partnerEntity.deleteWhere((t) => t.sharedById.equals(userId) & t.sharedWithId.equals(partnerId));
Partner _resultToPartner(TypedResult result) {
final user = result.readTable(_db.userEntity);
final partner = result.readTable(_db.partnerEntity);
return mapToPartner(user, partner);
}
}
@@ -267,7 +267,7 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
);
}
Future<void> updateRating(String assetId, int rating) async {
Future<void> updateRating(String assetId, int? rating) async {
await (_db.remoteExifEntity.update()..where((row) => row.assetId.equals(assetId))).write(
RemoteExifEntityCompanion(rating: Value(rating)),
);
@@ -1,6 +1,7 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' hide AssetVisibility;
import 'package:immich_mobile/infrastructure/repositories/api.repository.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:openapi/api.dart';
class SearchApiRepository extends ApiRepository {
@@ -20,50 +21,64 @@ class SearchApiRepository extends ApiRepository {
(filter.assetId != null && filter.assetId!.isNotEmpty)) {
return _api.searchSmart(
SmartSearchDto(
query: filter.context,
queryAssetId: filter.assetId,
language: filter.language,
country: filter.location.country,
state: filter.location.state,
city: filter.location.city,
make: filter.camera.make,
model: filter.camera.model,
takenAfter: filter.date.takenAfter,
takenBefore: filter.date.takenBefore,
visibility: filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline,
rating: filter.rating.rating,
isFavorite: filter.display.isFavorite ? true : null,
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
personIds: filter.people.map((e) => e.id).toList(),
tagIds: filter.tagIds,
type: type,
page: page,
size: 100,
query: filter.context == null ? const Optional.absent() : Optional.present(filter.context!),
queryAssetId: filter.assetId == null ? const Optional.absent() : Optional.present(filter.assetId!),
language: filter.language == null ? const Optional.absent() : Optional.present(filter.language!),
country: filter.location.country == null
? const Optional.absent()
: Optional.present(filter.location.country!),
state: filter.location.state == null ? const Optional.absent() : Optional.present(filter.location.state!),
city: filter.location.city == null ? const Optional.absent() : Optional.present(filter.location.city!),
make: filter.camera.make == null ? const Optional.absent() : Optional.present(filter.camera.make!),
model: filter.camera.model == null ? const Optional.absent() : Optional.present(filter.camera.model!),
takenAfter: filter.date.takenAfter == null
? const Optional.absent()
: Optional.present(filter.date.takenAfter!),
takenBefore: filter.date.takenBefore == null
? const Optional.absent()
: Optional.present(filter.date.takenBefore!),
visibility: Optional.present(filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline),
rating: filter.rating.rating.toOptional(),
isFavorite: filter.display.isFavorite ? const Optional.present(true) : const Optional.absent(),
isNotInAlbum: filter.display.isNotInAlbum ? const Optional.present(true) : const Optional.absent(),
personIds: Optional.present(filter.people.map((e) => e.id).toList()),
tagIds: filter.tagIds == null ? const Optional.absent() : Optional.present(filter.tagIds!),
type: type == null ? const Optional.absent() : Optional.present(type),
page: Optional.present(page),
size: const Optional.present(100),
),
);
}
return _api.searchAssets(
MetadataSearchDto(
originalFileName: filter.filename != null && filter.filename!.isNotEmpty ? filter.filename : null,
country: filter.location.country,
description: filter.description != null && filter.description!.isNotEmpty ? filter.description : null,
ocr: filter.ocr != null && filter.ocr!.isNotEmpty ? filter.ocr : null,
state: filter.location.state,
city: filter.location.city,
make: filter.camera.make,
model: filter.camera.model,
takenAfter: filter.date.takenAfter,
takenBefore: filter.date.takenBefore,
visibility: filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline,
rating: filter.rating.rating,
isFavorite: filter.display.isFavorite ? true : null,
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
personIds: filter.people.map((e) => e.id).toList(),
tagIds: filter.tagIds,
type: type,
page: page,
size: 1000,
originalFileName: filter.filename != null && filter.filename!.isNotEmpty
? Optional.present(filter.filename!)
: const Optional.absent(),
country: filter.location.country == null ? const Optional.absent() : Optional.present(filter.location.country!),
description: filter.description != null && filter.description!.isNotEmpty
? Optional.present(filter.description!)
: const Optional.absent(),
ocr: filter.ocr != null && filter.ocr!.isNotEmpty ? Optional.present(filter.ocr!) : const Optional.absent(),
state: filter.location.state == null ? const Optional.absent() : Optional.present(filter.location.state!),
city: filter.location.city == null ? const Optional.absent() : Optional.present(filter.location.city!),
make: filter.camera.make == null ? const Optional.absent() : Optional.present(filter.camera.make!),
model: filter.camera.model == null ? const Optional.absent() : Optional.present(filter.camera.model!),
takenAfter: filter.date.takenAfter == null
? const Optional.absent()
: Optional.present(filter.date.takenAfter!),
takenBefore: filter.date.takenBefore == null
? const Optional.absent()
: Optional.present(filter.date.takenBefore!),
visibility: Optional.present(filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline),
rating: filter.rating.rating.toOptional(),
isFavorite: filter.display.isFavorite ? const Optional.present(true) : const Optional.absent(),
isNotInAlbum: filter.display.isNotInAlbum ? const Optional.present(true) : const Optional.absent(),
personIds: Optional.present(filter.people.map((e) => e.id).toList()),
tagIds: filter.tagIds == null ? const Optional.absent() : Optional.present(filter.tagIds!),
type: type == null ? const Optional.absent() : Optional.present(type),
page: Optional.present(page),
size: const Optional.present(1000),
),
);
}
@@ -20,7 +20,7 @@ class SyncApiRepository {
}
Future<void> deleteSyncAck(List<SyncEntityType> types) {
return _api.syncApi.deleteSyncAck(SyncAckDeleteDto(types: types));
return _api.syncApi.deleteSyncAck(SyncAckDeleteDto(types: Optional.present(types)));
}
Future<void> streamChanges(
@@ -29,6 +29,7 @@ class SyncApiRepository {
Function()? onReset,
int batchSize = kSyncEventBatchSize,
http.Client? httpClient,
Future<void>? abortSignal,
}) async {
final stopwatch = Stopwatch()..start();
final client = httpClient ?? NetworkRepository.client;
@@ -36,7 +37,7 @@ class SyncApiRepository {
final headers = {'Content-Type': 'application/json', 'Accept': 'application/jsonlines+json'};
final request = http.Request('POST', Uri.parse(endpoint));
final request = http.AbortableRequest('POST', Uri.parse(endpoint), abortTrigger: abortSignal);
request.headers.addAll(headers);
request.body = jsonEncode(
SyncStreamDto(
@@ -73,6 +74,7 @@ class SyncApiRepository {
serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)
? SyncRequestType.assetFacesV2
: SyncRequestType.assetFacesV1,
if (serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)) SyncRequestType.assetOcrV1,
],
).toJson(),
);
@@ -203,6 +205,8 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
SyncEntityType.assetFaceV1: SyncAssetFaceV1.fromJson,
SyncEntityType.assetFaceV2: SyncAssetFaceV2.fromJson,
SyncEntityType.assetFaceDeleteV1: SyncAssetFaceDeleteV1.fromJson,
SyncEntityType.assetOcrV1: SyncAssetOcrV1.fromJson,
SyncEntityType.assetOcrDeleteV1: SyncAssetOcrDeleteV1.fromJson,
SyncEntityType.syncCompleteV1: _SyncEmptyDto.fromJson,
};
@@ -12,6 +12,7 @@ import 'package:immich_mobile/domain/models/user_metadata.model.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
@@ -69,6 +70,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
await _db.userMetadataEntity.deleteAll();
await _db.remoteAssetCloudIdEntity.deleteAll();
await _db.assetEditEntity.deleteAll();
await _db.assetOcrEntity.deleteAll();
});
} finally {
// re-enable FK even if the transaction throws, otherwise the connection
@@ -91,7 +93,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
email: Value(user.email),
hasProfileImage: Value(user.hasProfileImage),
profileChangedAt: Value(user.profileChangedAt),
avatarColor: Value(user.avatarColor?.toAvatarColor() ?? AvatarColor.primary),
avatarColor: Value(user.avatarColor.orElse(null)?.toAvatarColor() ?? AvatarColor.primary),
isAdmin: Value(user.isAdmin),
pinCode: Value(user.pinCode),
quotaSizeInBytes: Value(user.quotaSizeInBytes ?? 0),
@@ -133,7 +135,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
email: Value(user.email),
hasProfileImage: Value(user.hasProfileImage),
profileChangedAt: Value(user.profileChangedAt),
avatarColor: Value(user.avatarColor?.toAvatarColor() ?? AvatarColor.primary),
avatarColor: Value(user.avatarColor.orElse(null)?.toAvatarColor() ?? AvatarColor.primary),
);
batch.insert(_db.userEntity, companion.copyWith(id: Value(user.id)), onConflict: DoUpdate((_) => companion));
@@ -848,6 +850,52 @@ class SyncStreamRepository extends DriftDatabaseRepository {
}
}
Future<void> updateAssetOcrV1(Iterable<SyncAssetOcrV1> data) async {
try {
await _db.batch((batch) {
for (final assetOcr in data) {
final companion = AssetOcrEntityCompanion(
assetId: Value(assetOcr.assetId),
recognizedText: Value(assetOcr.text),
x1: Value(assetOcr.x1),
y1: Value(assetOcr.y1),
x2: Value(assetOcr.x2),
y2: Value(assetOcr.y2),
x3: Value(assetOcr.x3),
y3: Value(assetOcr.y3),
x4: Value(assetOcr.x4),
y4: Value(assetOcr.y4),
boxScore: Value(assetOcr.boxScore),
textScore: Value(assetOcr.textScore),
isVisible: Value(assetOcr.isVisible),
);
batch.insert(
_db.assetOcrEntity,
companion.copyWith(id: Value(assetOcr.id)),
onConflict: DoUpdate((_) => companion),
);
}
});
} catch (error, stack) {
_logger.severe('Error: updateAssetOcrV1', error, stack);
rethrow;
}
}
Future<void> deleteAssetOcrV1(Iterable<SyncAssetOcrDeleteV1> data) async {
try {
await _db.batch((batch) {
for (final assetOcr in data) {
batch.deleteWhere(_db.assetOcrEntity, (row) => row.id.equals(assetOcr.id));
}
});
} catch (error, stack) {
_logger.severe('Error: deleteAssetOcrV1', error, stack);
rethrow;
}
}
Future<void> pruneAssets() async {
try {
await _db.transaction(() async {
@@ -2,9 +2,17 @@ import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/models/user_metadata.model.dart';
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/mapper.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user_metadata.repository.dart';
class UserRepository {
final Drift _db;
const UserRepository(this._db);
Stream<Iterable<User>> getAll() => _db.select(_db.userEntity).map(mapToUser).watch();
}
class DriftAuthUserRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftAuthUserRepository(super.db) : _db = db;
@@ -5,24 +5,24 @@ import 'package:openapi/api.dart';
abstract final class ExifDtoConverter {
static ExifInfo fromDto(ExifResponseDto dto) {
return ExifInfo(
fileSize: dto.fileSizeInByte,
description: dto.description,
orientation: dto.orientation,
timeZone: dto.timeZone,
dateTimeOriginal: dto.dateTimeOriginal,
isFlipped: isOrientationFlipped(dto.orientation),
latitude: dto.latitude?.toDouble(),
longitude: dto.longitude?.toDouble(),
city: dto.city,
state: dto.state,
country: dto.country,
make: dto.make,
model: dto.model,
lens: dto.lensModel,
f: dto.fNumber?.toDouble(),
mm: dto.focalLength?.toDouble(),
iso: dto.iso?.toInt(),
exposureSeconds: exposureTimeToSeconds(dto.exposureTime),
fileSize: dto.fileSizeInByte.orElse(null),
description: dto.description.orElse(null),
orientation: dto.orientation.orElse(null),
timeZone: dto.timeZone.orElse(null),
dateTimeOriginal: dto.dateTimeOriginal.orElse(null),
isFlipped: isOrientationFlipped(dto.orientation.orElse(null)),
latitude: dto.latitude.orElse(null)?.toDouble(),
longitude: dto.longitude.orElse(null)?.toDouble(),
city: dto.city.orElse(null),
state: dto.state.orElse(null),
country: dto.country.orElse(null),
make: dto.make.orElse(null),
model: dto.model.orElse(null),
lens: dto.lensModel.orElse(null),
f: dto.fNumber.orElse(null)?.toDouble(),
mm: dto.focalLength.orElse(null)?.toDouble(),
iso: dto.iso.orElse(null)?.toInt(),
exposureSeconds: exposureTimeToSeconds(dto.exposureTime.orElse(null)),
);
}
@@ -40,7 +40,7 @@ abstract final class UserConverter {
updatedAt: DateTime.now(),
avatarColor: dto.avatarColor.toAvatarColor(),
memoryEnabled: false,
inTimeline: dto.inTimeline ?? false,
inTimeline: dto.inTimeline.orElse(null) ?? false,
isPartnerSharedBy: false,
isPartnerSharedWith: false,
profileChangedAt: dto.profileChangedAt,
+3
View File
@@ -24,6 +24,7 @@ import 'package:immich_mobile/pages/common/splash_screen.page.dart';
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
@@ -128,6 +129,7 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
case AppLifecycleState.resumed:
dPrint(() => "[APP STATE] resumed");
ref.read(appStateProvider.notifier).handleAppResume();
unawaited(ref.read(viewIntentHandlerProvider).onAppResumed());
break;
case AppLifecycleState.inactive:
dPrint(() => "[APP STATE] inactive");
@@ -233,6 +235,7 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
}
});
ref.read(viewIntentHandlerProvider).init();
ref.read(shareIntentUploadProvider.notifier).init();
}

Some files were not shown because too many files have changed in this diff Show More