diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 9563be6125..79126fd658 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -5,8 +5,7 @@
"immich-server",
"redis",
"database",
- "immich-machine-learning",
- "init"
+ "immich-machine-learning"
],
"dockerComposeFile": [
"../docker/docker-compose.dev.yml",
diff --git a/.devcontainer/mobile/container-compose-overrides.yml b/.devcontainer/mobile/container-compose-overrides.yml
index c0655e50e7..d6cd95018f 100644
--- a/.devcontainer/mobile/container-compose-overrides.yml
+++ b/.devcontainer/mobile/container-compose-overrides.yml
@@ -12,7 +12,6 @@ services:
- server_node_modules:/workspaces/immich/server/node_modules
- web_node_modules:/workspaces/immich/web/node_modules
- ${UPLOAD_LOCATION}/photos:/data
- - ${UPLOAD_LOCATION}/photos/upload:/data/upload
- /etc/localtime:/etc/localtime:ro
database:
diff --git a/.devcontainer/server/container-compose-overrides.yml b/.devcontainer/server/container-compose-overrides.yml
index 0fa8f2ff48..3be5cd8f3f 100644
--- a/.devcontainer/server/container-compose-overrides.yml
+++ b/.devcontainer/server/container-compose-overrides.yml
@@ -8,8 +8,7 @@ services:
- IMMICH_SERVER_URL=http://127.0.0.1:2283/
volumes: !override
- ..:/workspaces/immich
- - ${UPLOAD_LOCATION:-upload1-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
- - ${UPLOAD_LOCATION:-upload2-devcontainer-volume}${UPLOAD_LOCATION:+/photos/upload}:/data/upload
+ - ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
- /etc/localtime:/etc/localtime:ro
- pnpm-store:/usr/src/app/.pnpm-store
- server-node_modules:/usr/src/app/server/node_modules
@@ -24,9 +23,6 @@ services:
- coverage:/usr/src/app/web/coverage
immich-web:
env_file: !reset []
- init:
- env_file: !reset []
- command: sh -c 'find /data -maxdepth 1 ! -path "/data/postgres" -type d -exec chown ${UID:-0}:${GID:-0} {} + 2>/dev/null || true; for path in /usr/src/app/.pnpm-store /usr/src/app/server/node_modules /usr/src/app/server/dist /usr/src/app/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-0}:${GID:-0} "$$path" || true; done'
immich-machine-learning:
env_file: !reset []
database:
@@ -42,7 +38,5 @@ services:
redis:
env_file: !reset []
volumes:
- # Node modules for each service to avoid conflicts and ensure consistent dependencies
- upload1-devcontainer-volume:
- upload2-devcontainer-volume:
+ upload-devcontainer-volume:
postgres-devcontainer-volume:
diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml
index 71fa358942..1b57731b23 100644
--- a/.github/workflows/build-mobile.yml
+++ b/.github/workflows/build-mobile.yml
@@ -32,24 +32,18 @@ jobs:
permissions:
contents: read
outputs:
- should_run: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }}
+ should_run: ${{ steps.check.outputs.should_run }}
steps:
- - name: Checkout code
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- with:
- persist-credentials: false
-
- - id: found_paths
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
+ - name: Check what should run
+ id: check
+ uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
with:
filters: |
mobile:
- 'mobile/**'
- workflow:
- - '.github/workflows/build-mobile.yml'
- - name: Check if we should force jobs to run
- id: should_force
- run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch' }}" >> "$GITHUB_OUTPUT"
+ force-filters: |
+ - '.github/workflows/build-mobile.yml'
+ force-events: 'workflow_call,workflow_dispatch'
build-sign-android:
name: Build and sign Android
@@ -57,7 +51,7 @@ jobs:
permissions:
contents: read
# Skip when PR from a fork
- if: ${{ !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' && needs.pre-job.outputs.should_run == 'true' }}
+ if: ${{ !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
runs-on: mich
steps:
diff --git a/.github/workflows/close-duplicates.yml b/.github/workflows/close-duplicates.yml
index 0e831ff5d1..8470e0e18c 100644
--- a/.github/workflows/close-duplicates.yml
+++ b/.github/workflows/close-duplicates.yml
@@ -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:1669c75a5542333ff6b03c13d5fd259ea8d798188b84d5d99093d62e4542eb05
+ image: ghcr.io/immich-app/mdq:main@sha256:d8ae47cf2e6cf4e2559bd57a60b73674fe44f897cba2c2bddff2987a05be10a4
outputs:
checked: ${{ steps.get_checkbox.outputs.checked }}
steps:
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 644dc26854..503dd30d9a 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -50,7 +50,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0
+ uses: github/codeql-action/init@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -63,7 +63,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@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0
+ uses: github/codeql-action/autobuild@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3
# ℹ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -76,6 +76,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0
+ uses: github/codeql-action/analyze@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3
with:
category: '/language:${{matrix.language}}'
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 6e2bcdb84d..a630d27809 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -20,15 +20,11 @@ jobs:
permissions:
contents: read
outputs:
- should_run_server: ${{ steps.found_paths.outputs.server == 'true' || steps.should_force.outputs.should_force == 'true' }}
- should_run_ml: ${{ steps.found_paths.outputs.machine-learning == 'true' || steps.should_force.outputs.should_force == 'true' }}
+ should_run: ${{ steps.check.outputs.should_run }}
steps:
- - name: Checkout code
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- with:
- persist-credentials: false
- - id: found_paths
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
+ - name: Check what should run
+ id: check
+ uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
with:
filters: |
server:
@@ -38,14 +34,11 @@ jobs:
- 'i18n/**'
machine-learning:
- 'machine-learning/**'
- workflow:
- - '.github/workflows/docker.yml'
- - '.github/workflows/multi-runner-build.yml'
- - '.github/actions/image-build'
-
- - name: Check if we should force jobs to run
- id: should_force
- run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' }}" >> "$GITHUB_OUTPUT"
+ force-filters: |
+ - '.github/workflows/docker.yml'
+ - '.github/workflows/multi-runner-build.yml'
+ - '.github/actions/image-build'
+ force-events: 'workflow_dispatch,release'
retag_ml:
name: Re-Tag ML
@@ -53,7 +46,7 @@ jobs:
permissions:
contents: read
packages: write
- if: ${{ needs.pre-job.outputs.should_run_ml == 'false' && !github.event.pull_request.head.repo.fork }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).machine-learning == false && !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
strategy:
matrix:
@@ -82,7 +75,7 @@ jobs:
permissions:
contents: read
packages: write
- if: ${{ needs.pre-job.outputs.should_run_server == 'false' && !github.event.pull_request.head.repo.fork }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == false && !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
strategy:
matrix:
@@ -108,7 +101,7 @@ jobs:
machine-learning:
name: Build and Push ML
needs: pre-job
- if: ${{ needs.pre-job.outputs.should_run_ml == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).machine-learning == true }}
strategy:
fail-fast: false
matrix:
@@ -153,7 +146,7 @@ jobs:
server:
name: Build and Push Server
needs: pre-job
- if: ${{ needs.pre-job.outputs.should_run_server == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@129aeda75a450666ce96e8bc8126652e717917a7 # multi-runner-build-workflow-0.1.1
permissions:
contents: read
diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml
index 2514ee8639..9a35a0ae91 100644
--- a/.github/workflows/docs-build.yml
+++ b/.github/workflows/docs-build.yml
@@ -18,32 +18,28 @@ jobs:
permissions:
contents: read
outputs:
- should_run: ${{ steps.found_paths.outputs.docs == 'true' || steps.found_paths.outputs.open-api == 'true' || steps.should_force.outputs.should_force == 'true' }}
+ should_run: ${{ steps.check.outputs.should_run }}
steps:
- - name: Checkout code
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- with:
- persist-credentials: false
- - id: found_paths
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
+ - name: Check what should run
+ id: check
+ uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
with:
filters: |
docs:
- 'docs/**'
- workflow:
- - '.github/workflows/docs-build.yml'
open-api:
- 'open-api/immich-openapi-specs.json'
- - name: Check if we should force jobs to run
- id: should_force
- run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'release' || github.ref_name == 'main' }}" >> "$GITHUB_OUTPUT"
+ force-filters: |
+ - '.github/workflows/docs-build.yml'
+ force-events: 'release'
+ force-branches: 'main'
build:
name: Docs Build
needs: pre-job
permissions:
contents: read
- if: ${{ needs.pre-job.outputs.should_run == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).docs == true }}
runs-on: ubuntu-latest
defaults:
run:
diff --git a/.github/workflows/fix-format.yml b/.github/workflows/fix-format.yml
index 14043772ee..bec34c2713 100644
--- a/.github/workflows/fix-format.yml
+++ b/.github/workflows/fix-format.yml
@@ -28,6 +28,9 @@ jobs:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: true
+ - name: Setup pnpm
+ uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
+
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml
index add0ba7ae4..add68ffbd7 100644
--- a/.github/workflows/prepare-release.yml
+++ b/.github/workflows/prepare-release.yml
@@ -119,7 +119,7 @@ jobs:
name: release-apk-signed
- name: Create draft release
- uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
+ uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3
with:
draft: true
tag_name: ${{ env.IMMICH_VERSION }}
diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml
index 7f1ce1988e..f5e68fb42d 100644
--- a/.github/workflows/static_analysis.yml
+++ b/.github/workflows/static_analysis.yml
@@ -17,28 +17,23 @@ jobs:
permissions:
contents: read
outputs:
- should_run: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }}
+ should_run: ${{ steps.check.outputs.should_run }}
steps:
- - name: Checkout code
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- with:
- persist-credentials: false
- - id: found_paths
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
+ - name: Check what should run
+ id: check
+ uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
with:
filters: |
mobile:
- 'mobile/**'
- workflow:
- - '.github/workflows/static_analysis.yml'
- - name: Check if we should force jobs to run
- id: should_force
- run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'release' }}" >> "$GITHUB_OUTPUT"
+ force-filters: |
+ - '.github/workflows/static_analysis.yml'
+ force-events: 'workflow_dispatch,release'
mobile-dart-analyze:
name: Run Dart Code Analysis
needs: pre-job
- if: ${{ needs.pre-job.outputs.should_run == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -100,8 +95,9 @@ jobs:
- name: Run dart format
run: make format
- - name: Run dart custom_lint
- run: dart run custom_lint
+ # TODO: Re-enable after upgrading custom_lint
+ # - name: Run dart custom_lint
+ # run: dart run custom_lint
# TODO: Use https://github.com/CQLabs/dcm-action
- name: Run DCM
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index c3c356d6e5..773d14e171 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -14,23 +14,11 @@ jobs:
permissions:
contents: read
outputs:
- should_run_i18n: ${{ steps.found_paths.outputs.i18n == 'true' || steps.should_force.outputs.should_force == 'true' }}
- should_run_web: ${{ steps.found_paths.outputs.web == 'true' || steps.should_force.outputs.should_force == 'true' }}
- should_run_server: ${{ steps.found_paths.outputs.server == 'true' || steps.should_force.outputs.should_force == 'true' }}
- should_run_cli: ${{ steps.found_paths.outputs.cli == 'true' || steps.should_force.outputs.should_force == 'true' }}
- should_run_e2e: ${{ steps.found_paths.outputs.e2e == 'true' || steps.should_force.outputs.should_force == 'true' }}
- should_run_mobile: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }}
- should_run_ml: ${{ steps.found_paths.outputs.machine-learning == 'true' || steps.should_force.outputs.should_force == 'true' }}
- should_run_e2e_web: ${{ steps.found_paths.outputs.e2e == 'true' || steps.found_paths.outputs.web == 'true' || steps.should_force.outputs.should_force == 'true' }}
- should_run_e2e_server_cli: ${{ steps.found_paths.outputs.e2e == 'true' || steps.found_paths.outputs.server == 'true' || steps.found_paths.outputs.cli == 'true' || steps.should_force.outputs.should_force == 'true' }}
- should_run_.github: ${{ steps.found_paths.outputs['.github'] == 'true' || steps.should_force.outputs.should_force == 'true' }} # redundant to have should_force but if someone changes the trigger then this won't have to be changed
+ should_run: ${{ steps.check.outputs.should_run }}
steps:
- - name: Checkout code
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- with:
- persist-credentials: false
- - id: found_paths
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
+ - name: Check what should run
+ id: check
+ uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
with:
filters: |
i18n:
@@ -50,17 +38,16 @@ jobs:
- 'mobile/**'
machine-learning:
- 'machine-learning/**'
- workflow:
- - '.github/workflows/test.yml'
.github:
- '.github/**'
- - name: Check if we should force jobs to run
- id: should_force
- run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'workflow_dispatch' }}" >> "$GITHUB_OUTPUT"
+ force-filters: |
+ - '.github/workflows/test.yml'
+ force-events: 'workflow_dispatch'
+
server-unit-tests:
name: Test & Lint Server
needs: pre-job
- if: ${{ needs.pre-job.outputs.should_run_server == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -97,7 +84,7 @@ jobs:
cli-unit-tests:
name: Unit Test CLI
needs: pre-job
- if: ${{ needs.pre-job.outputs.should_run_cli == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).cli == true }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -137,7 +124,7 @@ jobs:
cli-unit-tests-win:
name: Unit Test CLI (Windows)
needs: pre-job
- if: ${{ needs.pre-job.outputs.should_run_cli == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).cli == true }}
runs-on: windows-latest
permissions:
contents: read
@@ -172,7 +159,7 @@ jobs:
web-lint:
name: Lint Web
needs: pre-job
- if: ${{ needs.pre-job.outputs.should_run_web == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).web == true }}
runs-on: mich
permissions:
contents: read
@@ -209,7 +196,7 @@ jobs:
web-unit-tests:
name: Test Web
needs: pre-job
- if: ${{ needs.pre-job.outputs.should_run_web == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).web == true }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -243,7 +230,7 @@ jobs:
i18n-tests:
name: Test i18n
needs: pre-job
- if: ${{ needs.pre-job.outputs.should_run_i18n == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -281,7 +268,7 @@ jobs:
e2e-tests-lint:
name: End-to-End Lint
needs: pre-job
- if: ${{ needs.pre-job.outputs.should_run_e2e == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).e2e == true }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -320,7 +307,7 @@ jobs:
server-medium-tests:
name: Medium Tests (Server)
needs: pre-job
- if: ${{ needs.pre-job.outputs.should_run_server == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -348,7 +335,7 @@ jobs:
e2e-tests-server-cli:
name: End-to-End Tests (Server & CLI)
needs: pre-job
- if: ${{ needs.pre-job.outputs.should_run_e2e_server_cli == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).e2e == true || fromJSON(needs.pre-job.outputs.should_run).server == true || fromJSON(needs.pre-job.outputs.should_run).cli == true }}
runs-on: ${{ matrix.runner }}
permissions:
contents: read
@@ -396,7 +383,7 @@ jobs:
e2e-tests-web:
name: End-to-End Tests (Web)
needs: pre-job
- if: ${{ needs.pre-job.outputs.should_run_e2e_web == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).e2e == true || fromJSON(needs.pre-job.outputs.should_run).web == true }}
runs-on: ${{ matrix.runner }}
permissions:
contents: read
@@ -449,7 +436,7 @@ jobs:
mobile-unit-tests:
name: Unit Test Mobile
needs: pre-job
- if: ${{ needs.pre-job.outputs.should_run_mobile == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -471,7 +458,7 @@ jobs:
ml-unit-tests:
name: Unit Test ML
needs: pre-job
- if: ${{ needs.pre-job.outputs.should_run_ml == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).machine-learning == true }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -507,7 +494,7 @@ jobs:
github-files-formatting:
name: .github Files Formatting
needs: pre-job
- if: ${{ needs.pre-job.outputs['should_run_.github'] == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run)['.github'] == true }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -594,7 +581,7 @@ jobs:
contents: read
services:
postgres:
- image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3@sha256:4f7ee144d4738ad02f6d9376defed7a767b748d185d47eba241578c26a63064b
+ image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3@sha256:da52bbead5d818adaa8077c8dcdaad0aaf93038c31ad8348b51f9f0ec1310a4d
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
diff --git a/.github/workflows/weblate-lock.yml b/.github/workflows/weblate-lock.yml
index d765db6c1a..36544d4eed 100644
--- a/.github/workflows/weblate-lock.yml
+++ b/.github/workflows/weblate-lock.yml
@@ -21,25 +21,24 @@ jobs:
permissions:
contents: read
outputs:
- should_run: ${{ steps.found_paths.outputs.i18n == 'true' }}
+ should_run: ${{ steps.check.outputs.should_run }}
steps:
- - name: Checkout code
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- with:
- persist-credentials: false
- - id: found_paths
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
+ - name: Check what should run
+ id: check
+ uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
with:
filters: |
i18n:
- 'i18n/!(en)**\.json'
+ exclude-branches: 'chore/translations'
+ skip-force-logic: 'true'
enforce-lock:
name: Check Weblate Lock
needs: [pre-job]
runs-on: ubuntu-latest
permissions: {}
- if: ${{ needs.pre-job.outputs.should_run == 'true' }}
+ if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }}
steps:
- name: Bot review status
env:
diff --git a/.gitignore b/.gitignore
index 25731cc2aa..3220701cc6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,6 +18,7 @@ mobile/libisar.dylib
mobile/openapi/test
mobile/openapi/doc
mobile/openapi/.openapi-generator/FILES
+mobile/ios/build
open-api/typescript-sdk/build
mobile/android/fastlane/report.xml
diff --git a/CODEOWNERS b/CODEOWNERS
index f9687762d0..8759cf2357 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -4,3 +4,4 @@
/web/ @danieldietzler
/machine-learning/ @mertalev
/e2e/ @danieldietzler
+/mobile/ @shenlong-tanwen
diff --git a/Makefile b/Makefile
index 8c5865448d..34fb408c41 100644
--- a/Makefile
+++ b/Makefile
@@ -1,13 +1,13 @@
-dev: prepare-volumes
+dev:
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
dev-down:
docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans
-dev-update: prepare-volumes
+dev-update:
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
-dev-scale: prepare-volumes
+dev-scale:
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
dev-docs:
@@ -23,7 +23,7 @@ e2e-update:
e2e-down:
docker compose -f ./e2e/docker-compose.yml down --remove-orphans
-prod:
+prod:
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
prod-down:
@@ -33,16 +33,16 @@ prod-scale:
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
.PHONY: open-api
-open-api: prepare-volumes
+open-api:
cd ./open-api && bash ./bin/generate-open-api.sh
-open-api-dart: prepare-volumes
+open-api-dart:
cd ./open-api && bash ./bin/generate-open-api.sh dart
-open-api-typescript: prepare-volumes
+open-api-typescript:
cd ./open-api && bash ./bin/generate-open-api.sh typescript
-sql: prepare-volumes
+sql:
pnpm --filter immich run sync:sql
attach-server:
@@ -68,32 +68,6 @@ VOLUME_DIRS = \
# Include .env file if it exists
-include docker/.env
-# Helper function to chown, on error suggest remediation and exit
-define safe_chown
- CURRENT_OWNER=$$(stat -c '%u:%g' "$(1)" 2>/dev/null || echo "none"); \
- DESIRED_OWNER="$(or $(UID),0):$(or $(GID),0)"; \
- if [ "$$CURRENT_OWNER" != "$$DESIRED_OWNER" ] && ! chown -v $(2) $$DESIRED_OWNER "$(1)" 2>/dev/null; then \
- echo "Permission denied when changing owner of volumes and upload location. Try running 'sudo make prepare-volumes' first."; \
- exit 1; \
- fi;
-endef
-# create empty directories and chown
-prepare-volumes:
- @$(foreach dir,$(VOLUME_DIRS),mkdir -p $(dir);)
- @$(foreach dir,$(VOLUME_DIRS),$(call safe_chown,$(dir),-R))
-ifneq ($(UPLOAD_LOCATION),)
-ifeq ($(filter /%,$(UPLOAD_LOCATION)),)
- @mkdir -p "docker/$(UPLOAD_LOCATION)/photos/upload"
- @$(call safe_chown,docker/$(UPLOAD_LOCATION),)
- @$(call safe_chown,docker/$(UPLOAD_LOCATION)/photos,-R)
-else
- @mkdir -p "$(UPLOAD_LOCATION)/photos/upload"
- @$(call safe_chown,$(UPLOAD_LOCATION),)
- @$(call safe_chown,$(UPLOAD_LOCATION)/photos,-R)
-endif
-endif
-
-
MODULES = e2e server web cli sdk docs .github
# directory to package name mapping function
diff --git a/cli/package.json b/cli/package.json
index 7e11d1a15a..5b9b2d810c 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -20,7 +20,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
- "@types/node": "^22.18.0",
+ "@types/node": "^22.18.1",
"@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
diff --git a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl
index b2b504665b..0869dd28bc 100644
--- a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl
+++ b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl
@@ -2,37 +2,37 @@
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" {
- version = "4.52.3"
- constraints = "4.52.3"
+ version = "4.52.5"
+ constraints = "4.52.5"
hashes = [
- "h1:3jU62KY4Oj3xzMwkTQWon1nlIvFkgTCqI93IzUGaa0c=",
- "h1:BWimtYXrvbzbbuoVcyobjQnXjjOb9X69JFTw+GuPxfk=",
- "h1:C/KvLEm8dVQ6zG2X4asLDtmw2JW/xu7E8MddtaXniO0=",
- "h1:Doo0xcLFf+CnfDWjsA7G1NvSLURuwcgyVy8k0NF1gJA=",
- "h1:Gc3FGDtR8lUWsi9VImnnE5/USDXiIwYsv4Hbl+d2lwY=",
- "h1:HsDY6s1gup5fW9TeuTUy85QMIld1nDOUFlwsfxIq1ig=",
- "h1:MnHkB56E4b/kT6WZigsZJnB5rgnCfDVbrLBNxIsEXPY=",
- "h1:O/FUQEqhtknJNdsaMbIBi2pLWBds2VvN5FsTVVntzb0=",
- "h1:OKQBynkp0J5DIf5FOl/NR3S2rvh89pY+t5wevYxdTJs=",
- "h1:On+vPsYV8U/J/8wFZPXjeAgNJqFFQj42vNOKuNKURkY=",
- "h1:SPkrMRJahxK0uum7FnUugbGN/JepHMH8M71DBtYrvG0=",
- "h1:bEh1ASPMiin3F36+hTfjMQTBnuDl2DzjzSCdova3JEM=",
- "h1:dtIK+x5Q1sh5SMPaHBHXhL9XDIqbRW0EBmVZ+KHQB8E=",
- "h1:kZcwWfODMWWyauZ66oaO/X+xXkqBtrbYwfUFEtspwEc=",
- "zh:53946fce4a631f1d98c61550821c88edede9169dfe5cc254e09a2ab207f76b3f",
- "zh:61654a21f1dd4331492d4ef77e9ebff066bc01e1281f92b925e5697c9138d681",
- "zh:6a54e9d129b276f052a2f1b73ad0b8735fe6a7403c6a8f6aa111e525eeefaf35",
- "zh:7692374e655c346a630b5a7cd776c5e0b2388900dcd7ab69a3af85d0c31c6c43",
+ "h1:+rfzF+16ZcWZWnTyW/p1HHTzYbPKX8Zt2nIFtR/+f+E=",
+ "h1:18bXaaOSq8MWKuMxo/4y7EB7/i7G90y5QsKHZRmkoDo=",
+ "h1:4vZVOpKeEQZsF2VrARRZFeL37Ed/gD4rRMtfnvWQres=",
+ "h1:BZOsTF83QPKXTAaYqxPKzdl1KRjk/L2qbPpFjM0w28A=",
+ "h1:CDuC+HXLvc1z6wkCRsSDcc/+QENIHEtssYshiWg3opA=",
+ "h1:DE+YFzLnqSe79pI2R4idRGx5QzLdrA7RXvngTkGfZ30=",
+ "h1:DfaJwH3Ml4yrRbdAY4AcDVy0QTQk5T3A622TXzS/u2E=",
+ "h1:EIDXP0W3kgIv2pecrFmqtK/DnlqkyckzBzhxKaXU+4A=",
+ "h1:EV4kYyaOnwGA0bh/3hU6Ezqnt1PFDxopH7i85e48IzY=",
+ "h1:M0iXabfzamU+MPDi0G9XACpbacFKMakmM+Z9HZ8HrsM=",
+ "h1:YWmCbGF/KbsrUzcYVBLscwLizidbp95TDQa0N2qpmVo=",
+ "h1:cxPcCB5gbrpUO1+IXkQYs1YTY50/0IlApCzGea0cwuQ=",
+ "h1:g6DldikTV2HXUu9uoeNY5FuLufgaYWF4ufgZg7wq62s=",
+ "h1:oi/Hrx9pwoQ+Z52CBC+rrowVH387EIj0qvnxQgDeI+0=",
+ "zh:1a3400cb38863b2585968d1876706bcfc67a148e1318a1d325c6c7704adc999b",
+ "zh:4c5062cb9e9da1676f06ae92b8370186d98976cc4c7030d3cd76df12af54282a",
+ "zh:52110f493b5f0587ef77a1cfd1a67001fd4c617b14c6502d732ab47352bdc2f7",
+ "zh:5aa536f9eaeb43823aaf2aa80e7d39b25ef2b383405ed034aa16a28b446a9238",
+ "zh:5cc39459a1c6be8a918f17054e4fbba573825ed5597dcada588fe99614d98a5b",
+ "zh:629ae6a7ba298815131da826474d199312d21cec53a4d5ded4fa56a692e6f072",
+ "zh:719cc7c75dc1d3eb30c22ff5102a017996d9788b948078c7e1c5b3446aeca661",
+ "zh:8698635a3ca04383c1e93b21d6963346bdae54d27177a48e4b1435b7f731731c",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
- "zh:8fe5b792a4d2b1c3a0e573649642962494faa00299baa6aaf813b9a43203dc02",
- "zh:a0f403a4862df90f09de65c6e939d6cfd069a8dda2dd33f82948bf6f5f1124ef",
- "zh:a25dc3eb60777b600f8f125d321fe7c50b811c5302b58e9a727ceb749a04e35d",
- "zh:a2f2ac7dc703c69d2e8c67c9cb5620b5348cb4fd6b98515fbe3f478517b56602",
- "zh:d452e7bd24445ee14166470cf50f3aca566d46cab5f26f1c5c988c0f3106b697",
- "zh:e10a52b0294735659eb3f0821ad2006ec097918efe58d31d37a5e3c47efef5f6",
- "zh:e28dd0954cef9f05adf4d4b440d6f134f605344dfa56307181996675e6550af2",
- "zh:f1e3b2f43a472280442f01ba71a3c06c9167432e553381132ea5c4a77e0b6dd5",
- "zh:f71fd63718d38fd43829861e91fe79e16d7b4c7c3d508ae3d077368d89b8e5a0",
- "zh:faf8d3da4b819c4ae8e565d2b1a684c6a948a086cb299189a5e7b30b2178409d",
+ "zh:8a9993f1dcadf1dd6ca43b23348abe374605d29945a2fafc07fb3457644e6a54",
+ "zh:b1b9a1e6bcc24d5863a664a411d2dc906373ae7a2399d2d65548ce7377057852",
+ "zh:b270184cdeec277218e84b94cb136fead753da717f9b9dc378e51907f3f00bb0",
+ "zh:dff2bc10071210181726ce270f954995fe42c696e61e2e8f874021fed02521e5",
+ "zh:e8e87b40b6a87dc097b0fdc20d3f725cec0d82abc9cc3755c1f89f8f6e8b0036",
+ "zh:ee964a6573d399a5dd22ce328fb38ca1207797a02248f14b2e4913ee390e7803",
]
}
diff --git a/deployment/modules/cloudflare/docs-release/config.tf b/deployment/modules/cloudflare/docs-release/config.tf
index 5f4d40b8f0..63347cf67e 100644
--- a/deployment/modules/cloudflare/docs-release/config.tf
+++ b/deployment/modules/cloudflare/docs-release/config.tf
@@ -5,7 +5,7 @@ terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
- version = "4.52.3"
+ version = "4.52.5"
}
}
}
diff --git a/deployment/modules/cloudflare/docs/.terraform.lock.hcl b/deployment/modules/cloudflare/docs/.terraform.lock.hcl
index b2b504665b..0869dd28bc 100644
--- a/deployment/modules/cloudflare/docs/.terraform.lock.hcl
+++ b/deployment/modules/cloudflare/docs/.terraform.lock.hcl
@@ -2,37 +2,37 @@
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" {
- version = "4.52.3"
- constraints = "4.52.3"
+ version = "4.52.5"
+ constraints = "4.52.5"
hashes = [
- "h1:3jU62KY4Oj3xzMwkTQWon1nlIvFkgTCqI93IzUGaa0c=",
- "h1:BWimtYXrvbzbbuoVcyobjQnXjjOb9X69JFTw+GuPxfk=",
- "h1:C/KvLEm8dVQ6zG2X4asLDtmw2JW/xu7E8MddtaXniO0=",
- "h1:Doo0xcLFf+CnfDWjsA7G1NvSLURuwcgyVy8k0NF1gJA=",
- "h1:Gc3FGDtR8lUWsi9VImnnE5/USDXiIwYsv4Hbl+d2lwY=",
- "h1:HsDY6s1gup5fW9TeuTUy85QMIld1nDOUFlwsfxIq1ig=",
- "h1:MnHkB56E4b/kT6WZigsZJnB5rgnCfDVbrLBNxIsEXPY=",
- "h1:O/FUQEqhtknJNdsaMbIBi2pLWBds2VvN5FsTVVntzb0=",
- "h1:OKQBynkp0J5DIf5FOl/NR3S2rvh89pY+t5wevYxdTJs=",
- "h1:On+vPsYV8U/J/8wFZPXjeAgNJqFFQj42vNOKuNKURkY=",
- "h1:SPkrMRJahxK0uum7FnUugbGN/JepHMH8M71DBtYrvG0=",
- "h1:bEh1ASPMiin3F36+hTfjMQTBnuDl2DzjzSCdova3JEM=",
- "h1:dtIK+x5Q1sh5SMPaHBHXhL9XDIqbRW0EBmVZ+KHQB8E=",
- "h1:kZcwWfODMWWyauZ66oaO/X+xXkqBtrbYwfUFEtspwEc=",
- "zh:53946fce4a631f1d98c61550821c88edede9169dfe5cc254e09a2ab207f76b3f",
- "zh:61654a21f1dd4331492d4ef77e9ebff066bc01e1281f92b925e5697c9138d681",
- "zh:6a54e9d129b276f052a2f1b73ad0b8735fe6a7403c6a8f6aa111e525eeefaf35",
- "zh:7692374e655c346a630b5a7cd776c5e0b2388900dcd7ab69a3af85d0c31c6c43",
+ "h1:+rfzF+16ZcWZWnTyW/p1HHTzYbPKX8Zt2nIFtR/+f+E=",
+ "h1:18bXaaOSq8MWKuMxo/4y7EB7/i7G90y5QsKHZRmkoDo=",
+ "h1:4vZVOpKeEQZsF2VrARRZFeL37Ed/gD4rRMtfnvWQres=",
+ "h1:BZOsTF83QPKXTAaYqxPKzdl1KRjk/L2qbPpFjM0w28A=",
+ "h1:CDuC+HXLvc1z6wkCRsSDcc/+QENIHEtssYshiWg3opA=",
+ "h1:DE+YFzLnqSe79pI2R4idRGx5QzLdrA7RXvngTkGfZ30=",
+ "h1:DfaJwH3Ml4yrRbdAY4AcDVy0QTQk5T3A622TXzS/u2E=",
+ "h1:EIDXP0W3kgIv2pecrFmqtK/DnlqkyckzBzhxKaXU+4A=",
+ "h1:EV4kYyaOnwGA0bh/3hU6Ezqnt1PFDxopH7i85e48IzY=",
+ "h1:M0iXabfzamU+MPDi0G9XACpbacFKMakmM+Z9HZ8HrsM=",
+ "h1:YWmCbGF/KbsrUzcYVBLscwLizidbp95TDQa0N2qpmVo=",
+ "h1:cxPcCB5gbrpUO1+IXkQYs1YTY50/0IlApCzGea0cwuQ=",
+ "h1:g6DldikTV2HXUu9uoeNY5FuLufgaYWF4ufgZg7wq62s=",
+ "h1:oi/Hrx9pwoQ+Z52CBC+rrowVH387EIj0qvnxQgDeI+0=",
+ "zh:1a3400cb38863b2585968d1876706bcfc67a148e1318a1d325c6c7704adc999b",
+ "zh:4c5062cb9e9da1676f06ae92b8370186d98976cc4c7030d3cd76df12af54282a",
+ "zh:52110f493b5f0587ef77a1cfd1a67001fd4c617b14c6502d732ab47352bdc2f7",
+ "zh:5aa536f9eaeb43823aaf2aa80e7d39b25ef2b383405ed034aa16a28b446a9238",
+ "zh:5cc39459a1c6be8a918f17054e4fbba573825ed5597dcada588fe99614d98a5b",
+ "zh:629ae6a7ba298815131da826474d199312d21cec53a4d5ded4fa56a692e6f072",
+ "zh:719cc7c75dc1d3eb30c22ff5102a017996d9788b948078c7e1c5b3446aeca661",
+ "zh:8698635a3ca04383c1e93b21d6963346bdae54d27177a48e4b1435b7f731731c",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
- "zh:8fe5b792a4d2b1c3a0e573649642962494faa00299baa6aaf813b9a43203dc02",
- "zh:a0f403a4862df90f09de65c6e939d6cfd069a8dda2dd33f82948bf6f5f1124ef",
- "zh:a25dc3eb60777b600f8f125d321fe7c50b811c5302b58e9a727ceb749a04e35d",
- "zh:a2f2ac7dc703c69d2e8c67c9cb5620b5348cb4fd6b98515fbe3f478517b56602",
- "zh:d452e7bd24445ee14166470cf50f3aca566d46cab5f26f1c5c988c0f3106b697",
- "zh:e10a52b0294735659eb3f0821ad2006ec097918efe58d31d37a5e3c47efef5f6",
- "zh:e28dd0954cef9f05adf4d4b440d6f134f605344dfa56307181996675e6550af2",
- "zh:f1e3b2f43a472280442f01ba71a3c06c9167432e553381132ea5c4a77e0b6dd5",
- "zh:f71fd63718d38fd43829861e91fe79e16d7b4c7c3d508ae3d077368d89b8e5a0",
- "zh:faf8d3da4b819c4ae8e565d2b1a684c6a948a086cb299189a5e7b30b2178409d",
+ "zh:8a9993f1dcadf1dd6ca43b23348abe374605d29945a2fafc07fb3457644e6a54",
+ "zh:b1b9a1e6bcc24d5863a664a411d2dc906373ae7a2399d2d65548ce7377057852",
+ "zh:b270184cdeec277218e84b94cb136fead753da717f9b9dc378e51907f3f00bb0",
+ "zh:dff2bc10071210181726ce270f954995fe42c696e61e2e8f874021fed02521e5",
+ "zh:e8e87b40b6a87dc097b0fdc20d3f725cec0d82abc9cc3755c1f89f8f6e8b0036",
+ "zh:ee964a6573d399a5dd22ce328fb38ca1207797a02248f14b2e4913ee390e7803",
]
}
diff --git a/deployment/modules/cloudflare/docs/config.tf b/deployment/modules/cloudflare/docs/config.tf
index 5f4d40b8f0..63347cf67e 100644
--- a/deployment/modules/cloudflare/docs/config.tf
+++ b/deployment/modules/cloudflare/docs/config.tf
@@ -5,7 +5,7 @@ terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
- version = "4.52.3"
+ version = "4.52.5"
}
}
}
diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml
index 1bc1908d4e..f97cf0ca0d 100644
--- a/docker/docker-compose.dev.yml
+++ b/docker/docker-compose.dev.yml
@@ -21,16 +21,14 @@ services:
# extends:
# file: hwaccel.transcoding.yml
# service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
- user: '${UID:-0}:${GID:-0}'
build:
context: ../
- dockerfile: server/Dockerfile
+ dockerfile: server/Dockerfile.dev
target: dev
restart: unless-stopped
volumes:
- ..:/usr/src/app
- ${UPLOAD_LOCATION}/photos:/data
- - ${UPLOAD_LOCATION}/photos/upload:/data/upload
- /etc/localtime:/etc/localtime:ro
- pnpm-store:/usr/src/app/.pnpm-store
- server-node_modules:/usr/src/app/server/node_modules
@@ -72,20 +70,15 @@ services:
condition: service_started
database:
condition: service_started
- init:
- condition: service_completed_successfully
healthcheck:
disable: false
immich-web:
container_name: immich_web
image: immich-web-dev:latest
- # Needed for rootless docker setup, see https://github.com/moby/moby/issues/45919
- # user: 0:0
- user: '${UID:-0}:${GID:-0}'
build:
context: ../
- dockerfile: server/Dockerfile
+ dockerfile: server/Dockerfile.dev
target: dev
command: ['immich-web']
env_file:
@@ -114,8 +107,6 @@ services:
depends_on:
immich-server:
condition: service_started
- init:
- condition: service_completed_successfully
immich-machine-learning:
container_name: immich_machine_learning
@@ -149,7 +140,7 @@ services:
database:
container_name: immich_postgres
- image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:8d292bdb796aa58bbbaa47fe971c8516f6f57d6a47e7172e62754feb6ed4e7b0
+ image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:c44be5f2871c59362966d71eab4268170eb6f5653c0e6170184e72b38ffdf107
env_file:
- .env
environment:
@@ -183,25 +174,6 @@ services:
# volumes:
# - grafana-data:/var/lib/grafana
- init:
- container_name: init
- image: busybox@sha256:ab33eacc8251e3807b85bb6dba570e4698c3998eca6f0fc2ccb60575a563ea74
- env_file:
- - .env
- user: 0:0
- command: sh -c 'find /data -maxdepth 1 -type d -exec chown ${UID:-0}:${GID:-0} {} + 2>/dev/null || true; for path in /usr/src/app/.pnpm-store /usr/src/app/server/node_modules /usr/src/app/server/dist /usr/src/app/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-0}:${GID:-0} "$$path" || true; done'
- volumes:
- - pnpm-store:/usr/src/app/.pnpm-store
- - server-node_modules:/usr/src/app/server/node_modules
- - web-node_modules:/usr/src/app/web/node_modules
- - github-node_modules:/usr/src/app/.github/node_modules
- - cli-node_modules:/usr/src/app/cli/node_modules
- - docs-node_modules:/usr/src/app/docs/node_modules
- - e2e-node_modules:/usr/src/app/e2e/node_modules
- - sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
- - app-node_modules:/usr/src/app/node_modules
- - sveltekit:/usr/src/app/web/.svelte-kit
- - coverage:/usr/src/app/web/coverage
volumes:
model-cache:
prometheus-data:
diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml
index f7d1f564cf..c3fb9c7736 100644
--- a/docker/docker-compose.prod.yml
+++ b/docker/docker-compose.prod.yml
@@ -63,7 +63,7 @@ services:
database:
container_name: immich_postgres
- image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:8d292bdb796aa58bbbaa47fe971c8516f6f57d6a47e7172e62754feb6ed4e7b0
+ image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:c44be5f2871c59362966d71eab4268170eb6f5653c0e6170184e72b38ffdf107
env_file:
- .env
environment:
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index c401d4cfc7..3316c17839 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -56,7 +56,7 @@ services:
database:
container_name: immich_postgres
- image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:8d292bdb796aa58bbbaa47fe971c8516f6f57d6a47e7172e62754feb6ed4e7b0
+ image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:c44be5f2871c59362966d71eab4268170eb6f5653c0e6170184e72b38ffdf107
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md
index 928e0b26e5..4e081c8966 100644
--- a/docs/docs/install/environment-variables.md
+++ b/docs/docs/install/environment-variables.md
@@ -169,8 +169,6 @@ Redis (Sentinel) URL example JSON before encoding:
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
| `MACHINE_LEARNING_DEVICE_IDS`\*4 | Device IDs to use in multi-GPU environments | `0` | machine learning |
| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning |
-| `MACHINE_LEARNING_PING_TIMEOUT` | How long (ms) to wait for a PING response when checking if an ML server is available | `2000` | server |
-| `MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME` | How long to ignore ML servers that are offline before trying again | `30000` | server |
| `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning |
| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spinned up while inferencing. | `1` | machine learning |
diff --git a/docs/src/components/community-guides.tsx b/docs/src/components/community-guides.tsx
index 49ba7a8a08..08c8e096d9 100644
--- a/docs/src/components/community-guides.tsx
+++ b/docs/src/components/community-guides.tsx
@@ -28,6 +28,12 @@ const guides: CommunityGuidesProps[] = [
description: `synchronize folders in imported library with albums having the folders name.`,
url: 'https://github.com/immich-app/immich/discussions/3382',
},
+ {
+ title: 'Immich Podman Quadlets Handbook',
+ description:
+ 'A rewrite of the original Immich Docker Compose file using Podman Quadlets, with a set of extra guides in the repository’s wiki.',
+ url: 'https://github.com/linux-universe/immich-podman-quadlets/blob/main/README.md',
+ },
{
title: 'Podman/Quadlets Install',
description: 'Documentation for simple podman setup using quadlets.',
diff --git a/docs/src/components/community-projects.tsx b/docs/src/components/community-projects.tsx
index 930cff66c1..efce831df0 100644
--- a/docs/src/components/community-projects.tsx
+++ b/docs/src/components/community-projects.tsx
@@ -110,6 +110,16 @@ const projects: CommunityProjectProps[] = [
description: 'A tiny, zero-login web app for collecting photos/videos from anyone into your Immich server.',
url: 'https://github.com/Nasogaa/immich-drop',
},
+ {
+ title: 'Immich Birthday Sync',
+ description: 'Bulk-upload and -download birthdays, with CardDAV sync support',
+ url: 'https://github.com/sid3windr/immich-birthday',
+ },
+ {
+ title: 'Immich Stack',
+ description: 'Auto-stack photos with identical filenames and differing extensions (i.e. JPG+RAW)',
+ url: 'https://github.com/sid3windr/immich-stack',
+ },
];
function CommunityProject({ title, description, url }: CommunityProjectProps): JSX.Element {
diff --git a/docs/src/pages/errors.md b/docs/src/pages/errors.md
index 5f73162a61..fed72f21c7 100644
--- a/docs/src/pages/errors.md
+++ b/docs/src/pages/errors.md
@@ -2,7 +2,17 @@
## TypeORM Upgrade
-In order to update to Immich to `v1.137.0` (or above), the application must be started at least once on a version in the range between `1.132.0` and `1.136.0`. Doing so will complete database schema upgrades that are required for `v1.137.0` (and above). After Immich has successfully updated to a version in this range, you can now attempt to update to v1.137.0 (or above). We recommend users upgrade to `1.132.0` since it does not have any other breaking changes.
+If you encountered "Migrations failed: Error: Invalid upgrade path" then perform an intermediate upgrade to `v1.132.3` first.
+
+:::tip
+We recommend users upgrade to `v1.132.3` since it does not have any breaking changes or bugs on this upgrade path.
+:::
+
+In order to update to Immich `v1.137.0` or above, the application must be started at least once on a version in the range between `1.132.0` and `1.136.0`. Doing so will complete database schema upgrades that are required for `v1.137.0` (and above). After Immich has successfully updated to a version in this range, you can now attempt to update to `v1.137.0` (or above).
+
+:::caution
+Avoid `v1.136.0` if upgrading from `v1.131.0` (or earlier) due to a bug blocking this upgrade in some installations.
+:::
## Inconsistent Media Location
diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml
index 983125e4ad..6aba8ff72a 100644
--- a/e2e/docker-compose.yml
+++ b/e2e/docker-compose.yml
@@ -38,7 +38,7 @@ services:
image: redis:6.2-alpine@sha256:7fe72c486b910f6b1a9769c937dad5d63648ddee82e056f47417542dd40825bb
database:
- image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:7a4469b9484e37bf2630a60bc2f02f086dae898143b599ecc1c93f619849ef6b
+ image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:11ced39d65a92a54d12890ced6a26cc2003f92697d6f0d4d944b98459dba7138
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
environment:
POSTGRES_PASSWORD: postgres
diff --git a/e2e/package.json b/e2e/package.json
index 9356538d7c..737f488a50 100644
--- a/e2e/package.json
+++ b/e2e/package.json
@@ -25,7 +25,7 @@
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
- "@types/node": "^22.18.0",
+ "@types/node": "^22.18.1",
"@types/oidc-provider": "^9.0.0",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
diff --git a/i18n/en.json b/i18n/en.json
index 855bc9d927..1fdcd54397 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -123,6 +123,13 @@
"logging_enable_description": "Enable logging",
"logging_level_description": "When enabled, what log level to use.",
"logging_settings": "Logging",
+ "machine_learning_availability_checks": "Availability checks",
+ "machine_learning_availability_checks_description": "Automatically detect and prefer available machine learning servers",
+ "machine_learning_availability_checks_enabled": "Enable availability checks",
+ "machine_learning_availability_checks_interval": "Check interval",
+ "machine_learning_availability_checks_interval_description": "Interval in milliseconds between availability checks",
+ "machine_learning_availability_checks_timeout": "Request timeout",
+ "machine_learning_availability_checks_timeout_description": "Timeout in milliseconds for availability checks",
"machine_learning_clip_model": "CLIP model",
"machine_learning_clip_model_description": "The name of a CLIP model listed here. Note that you must re-run the 'Smart Search' job for all images upon changing a model.",
"machine_learning_duplicate_detection": "Duplicate Detection",
@@ -387,8 +394,6 @@
"admin_password": "Admin Password",
"administration": "Administration",
"advanced": "Advanced",
- "advanced_settings_beta_timeline_subtitle": "Try the new app experience",
- "advanced_settings_beta_timeline_title": "Beta Timeline",
"advanced_settings_enable_alternate_media_filter_subtitle": "Use this option to filter media during sync based on alternate criteria. Only try this if you have issues with the app detecting all albums.",
"advanced_settings_enable_alternate_media_filter_title": "[EXPERIMENTAL] Use alternate device album sync filter",
"advanced_settings_log_level_title": "Log level: {level}",
@@ -425,6 +430,7 @@
"album_remove_user_confirmation": "Are you sure you want to remove {user}?",
"album_search_not_found": "No albums found matching your search",
"album_share_no_users": "Looks like you have shared this album with all users or you don't have any user to share with.",
+ "album_summary": "Album summary",
"album_updated": "Album updated",
"album_updated_setting_description": "Receive an email notification when a shared album has new assets",
"album_user_left": "Left {album}",
@@ -496,6 +502,8 @@
"asset_restored_successfully": "Asset restored successfully",
"asset_skipped": "Skipped",
"asset_skipped_in_trash": "In trash",
+ "asset_trashed": "Asset trashed",
+ "asset_troubleshoot": "Asset Troubleshoot",
"asset_uploaded": "Uploaded",
"asset_uploading": "Uploading…",
"asset_viewer_settings_subtitle": "Manage your gallery viewer settings",
@@ -529,8 +537,10 @@
"autoplay_slideshow": "Autoplay slideshow",
"back": "Back",
"back_close_deselect": "Back, close, or deselect",
+ "background_backup_running_error": "Background backup is currently running, cannot start manual backup",
"background_location_permission": "Background location permission",
"background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name",
+ "background_options": "Background Options",
"backup": "Backup",
"backup_album_selection_page_albums_device": "Albums on device ({count})",
"backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude",
@@ -538,6 +548,7 @@
"backup_album_selection_page_select_albums": "Select albums",
"backup_album_selection_page_selection_info": "Selection Info",
"backup_album_selection_page_total_assets": "Total unique assets",
+ "backup_albums_sync": "Backup albums synchronization",
"backup_all": "All",
"backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…",
"backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…",
@@ -654,6 +665,8 @@
"change_pin_code": "Change PIN code",
"change_your_password": "Change your password",
"changed_visibility_successfully": "Changed visibility successfully",
+ "charging": "Charging",
+ "charging_requirement_mobile_backup": "Background backup requires the device to be charging",
"check_corrupt_asset_backup": "Check for corrupt asset backups",
"check_corrupt_asset_backup_button": "Perform check",
"check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.",
@@ -740,6 +753,7 @@
"create_user": "Create user",
"created": "Created",
"created_at": "Created",
+ "creating_linked_albums": "Creating linked albums...",
"crop": "Crop",
"curated_object_page_title": "Things",
"current_device": "Current device",
@@ -889,7 +903,9 @@
"error": "Error",
"error_change_sort_album": "Failed to change album sort order",
"error_delete_face": "Error deleting face from asset",
+ "error_getting_places": "Error getting places",
"error_loading_image": "Error loading image",
+ "error_loading_partners": "Error loading partners: {error}",
"error_saving_image": "Error: {error}",
"error_tag_face_bounding_box": "Error tagging face - cannot get bounding box coordinates",
"error_title": "Error - Something went wrong",
@@ -904,6 +920,7 @@
"cant_get_number_of_comments": "Can't get number of comments",
"cant_search_people": "Can't search people",
"cant_search_places": "Can't search places",
+ "clipboard_unsupported_mime_type": "The system clipboard does not support copying this type of content: {mimeType}",
"error_adding_assets_to_album": "Error adding assets to album",
"error_adding_users_to_album": "Error adding users to album",
"error_deleting_shared_user": "Error deleting shared user",
@@ -1054,6 +1071,7 @@
"favorites_page_no_favorites": "No favorite assets found",
"feature_photo_updated": "Feature photo updated",
"features": "Features",
+ "features_in_development": "Features in Development",
"features_setting_description": "Manage the app features",
"file_name": "File name",
"file_name_or_extension": "File name or extension",
@@ -1217,6 +1235,7 @@
"local": "Local",
"local_asset_cast_failed": "Unable to cast an asset that is not uploaded to the server",
"local_assets": "Local Assets",
+ "local_media_summary": "Local Media Summary",
"local_network": "Local network",
"local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network",
"location_permission": "Location permission",
@@ -1228,6 +1247,7 @@
"location_picker_longitude_hint": "Enter your longitude here",
"lock": "Lock",
"locked_folder": "Locked Folder",
+ "log_detail_title": "Log Detail",
"log_out": "Log out",
"log_out_all_devices": "Log Out All Devices",
"logged_in_as": "Logged in as {user}",
@@ -1258,6 +1278,7 @@
"login_password_changed_success": "Password updated successfully",
"logout_all_device_confirmation": "Are you sure you want to log out all devices?",
"logout_this_device_confirmation": "Are you sure you want to log out this device?",
+ "logs": "Logs",
"longitude": "Longitude",
"look": "Look",
"loop_videos": "Loop videos",
@@ -1300,6 +1321,7 @@
"mark_as_read": "Mark as read",
"marked_all_as_read": "Marked all as read",
"matches": "Matches",
+ "matching_assets": "Matching Assets",
"media_type": "Media type",
"memories": "Memories",
"memories_all_caught_up": "All caught up",
@@ -1340,6 +1362,7 @@
"name_or_nickname": "Name or nickname",
"network_requirement_photos_upload": "Use cellular data to backup photos",
"network_requirement_videos_upload": "Use cellular data to backup videos",
+ "network_requirements": "Network Requirements",
"network_requirements_updated": "Network requirements changed, resetting backup queue",
"networking_settings": "Networking",
"networking_subtitle": "Manage the server endpoint settings",
@@ -1350,6 +1373,7 @@
"new_person": "New person",
"new_pin_code": "New PIN code",
"new_pin_code_subtitle": "This is your first time accessing the locked folder. Create a PIN code to securely access this page",
+ "new_timeline": "New Timeline",
"new_user_created": "New user created",
"new_version_available": "NEW VERSION AVAILABLE",
"newest_first": "Newest first",
@@ -1363,20 +1387,25 @@
"no_assets_message": "CLICK TO UPLOAD YOUR FIRST PHOTO",
"no_assets_to_show": "No assets to show",
"no_cast_devices_found": "No cast devices found",
+ "no_checksum_local": "No checksum available - cannot fetch local assets",
+ "no_checksum_remote": "No checksum available - cannot fetch remote asset",
"no_duplicates_found": "No duplicates were found.",
"no_exif_info_available": "No exif info available",
"no_explore_results_message": "Upload more photos to explore your collection.",
"no_favorites_message": "Add favorites to quickly find your best pictures and videos",
"no_libraries_message": "Create an external library to view your photos and videos",
+ "no_local_assets_found": "No local assets found with this checksum",
"no_locked_photos_message": "Photos and videos in the locked folder are hidden and won't show up as you browse or search your library.",
"no_name": "No Name",
"no_notifications": "No notifications",
"no_people_found": "No matching people found",
"no_places": "No places",
+ "no_remote_assets_found": "No remote assets found with this checksum",
"no_results": "No results",
"no_results_description": "Try a synonym or more general keyword",
"no_shared_albums_message": "Create an album to share photos and videos with people in your network",
"no_uploads_in_progress": "No uploads in progress",
+ "not_available": "N/A",
"not_in_any_album": "Not in any album",
"not_selected": "Not selected",
"note_apply_storage_label_to_previously_uploaded assets": "Note: To apply the Storage Label to previously uploaded assets, run the",
@@ -1587,6 +1616,7 @@
"regenerating_thumbnails": "Regenerating thumbnails",
"remote": "Remote",
"remote_assets": "Remote Assets",
+ "remote_media_summary": "Remote Media Summary",
"remove": "Remove",
"remove_assets_album_confirmation": "Are you sure you want to remove {count, plural, one {# asset} other {# assets}} from the album?",
"remove_assets_shared_link_confirmation": "Are you sure you want to remove {count, plural, one {# asset} other {# assets}} from this shared link?",
@@ -1867,6 +1897,7 @@
"show_slideshow_transition": "Show slideshow transition",
"show_supporter_badge": "Supporter badge",
"show_supporter_badge_description": "Show a supporter badge",
+ "show_text_search_menu": "Show text search menu",
"shuffle": "Shuffle",
"sidebar": "Sidebar",
"sidebar_display_description": "Display a link to the view in the sidebar",
@@ -1897,6 +1928,7 @@
"stacktrace": "Stacktrace",
"start": "Start",
"start_date": "Start date",
+ "start_date_before_end_date": "Start date must be before end date",
"state": "State",
"status": "Status",
"stop_casting": "Stop casting",
@@ -2099,5 +2131,6 @@
"yes": "Yes",
"you_dont_have_any_shared_links": "You don't have any shared links",
"your_wifi_name": "Your Wi-Fi name",
- "zoom_image": "Zoom Image"
+ "zoom_image": "Zoom Image",
+ "zoom_to_bounds": "Zoom to bounds"
}
diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile
index dd8d8ad7e8..e4ed643375 100644
--- a/machine-learning/Dockerfile
+++ b/machine-learning/Dockerfile
@@ -22,7 +22,7 @@ FROM builder-cpu AS builder-rknn
# Warning: 25GiB+ disk space required to pull this image
# TODO: find a way to reduce the image size
-FROM rocm/dev-ubuntu-22.04:6.3.4-complete@sha256:1f7e92ca7e3a3785680473329ed1091fc99db3e90fcb3a1688f2933e870ed76b AS builder-rocm
+FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:1f7e92ca7e3a3785680473329ed1091fc99db3e90fcb3a1688f2933e870ed76b AS builder-rocm
# renovate: datasource=github-releases depName=Microsoft/onnxruntime
ARG ONNXRUNTIME_VERSION="v1.20.1"
@@ -99,7 +99,7 @@ COPY --from=builder-cuda /usr/local/bin/python3 /usr/local/bin/python3
COPY --from=builder-cuda /usr/local/lib/python3.11 /usr/local/lib/python3.11
COPY --from=builder-cuda /usr/local/lib/libpython3.11.so /usr/local/lib/libpython3.11.so
-FROM rocm/dev-ubuntu-22.04:6.3.4-complete@sha256:1f7e92ca7e3a3785680473329ed1091fc99db3e90fcb3a1688f2933e870ed76b AS prod-rocm
+FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:1f7e92ca7e3a3785680473329ed1091fc99db3e90fcb3a1688f2933e870ed76b AS prod-rocm
FROM prod-cpu AS prod-armnn
diff --git a/mise.lock b/mise.lock
deleted file mode 100644
index 112b1ca6eb..0000000000
--- a/mise.lock
+++ /dev/null
@@ -1,34 +0,0 @@
-[tools.dart]
-version = "3.8.2"
-backend = "asdf:dart"
-
-[tools.flutter]
-version = "3.32.8-stable"
-backend = "asdf:flutter"
-
-[tools."github:CQLabs/homebrew-dcm"]
-version = "1.31.4"
-backend = "github:CQLabs/homebrew-dcm"
-
-[tools."github:CQLabs/homebrew-dcm".platforms.linux-x64]
-checksum = "blake3:e9df5b765df327e1248fccf2c6165a89d632a065667f99c01765bf3047b94955"
-size = 8821083
-url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.31.4/dcm-linux-x64-release.zip"
-
-[tools.node]
-version = "22.18.0"
-backend = "core:node"
-
-[tools.node.platforms.linux-x64]
-checksum = "sha256:a2e703725d8683be86bb5da967bf8272f4518bdaf10f21389e2b2c9eaeae8c8a"
-size = 54824343
-url = "https://nodejs.org/dist/v22.18.0/node-v22.18.0-linux-x64.tar.gz"
-
-[tools.pnpm]
-version = "10.14.0"
-backend = "aqua:pnpm/pnpm"
-
-[tools.pnpm.platforms.linux-x64]
-checksum = "blake3:13dfa46b7173d3cad3bad60a756a492ecf0bce48b23eb9f793e7ccec5a09b46d"
-size = 66231525
-url = "https://github.com/pnpm/pnpm/releases/download/v10.14.0/pnpm-linux-x64"
diff --git a/mise.toml b/mise.toml
index 47acb66b21..c741b0c71a 100644
--- a/mise.toml
+++ b/mise.toml
@@ -1,7 +1,7 @@
[tools]
node = "22.19.0"
-flutter = "3.32.8"
-pnpm = "10.14.0"
+flutter = "3.35.4"
+pnpm = "10.15.1"
dart = "3.8.2"
[tools."github:CQLabs/homebrew-dcm"]
@@ -11,7 +11,6 @@ postinstall = "chmod +x $MISE_TOOL_INSTALL_PATH/dcm"
[settings]
experimental = true
-lockfile = true
pin = true
# .github
@@ -300,7 +299,7 @@ run = "tsc --noEmit"
depends = "web:svelte-kit-sync"
env._.path = "web/node_modules/.bin"
dir = "web"
-run = "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'reactive_declaration_non_reactive_property:ignore' --ignore src/lib/components/photos-page/asset-grid.svelte"
+run = "svelte-check --no-tsconfig --fail-on-warnings"
[tasks."web:checklist"]
run = [
diff --git a/mobile/.fvmrc b/mobile/.fvmrc
index 3ca65ffc7c..a4d5f6d9b7 100644
--- a/mobile/.fvmrc
+++ b/mobile/.fvmrc
@@ -1,3 +1,3 @@
{
- "flutter": "3.32.8"
+ "flutter": "3.35.4"
}
\ No newline at end of file
diff --git a/mobile/.vscode/settings.json b/mobile/.vscode/settings.json
index 9a9fb67ce3..9c6057e582 100644
--- a/mobile/.vscode/settings.json
+++ b/mobile/.vscode/settings.json
@@ -1,8 +1,8 @@
{
- "dart.flutterSdkPath": ".fvm/versions/3.32.8",
+ "dart.flutterSdkPath": ".fvm/versions/3.35.4",
"dart.lineLength": 120,
"[dart]": {
- "editor.rulers": [120],
+ "editor.rulers": [120]
},
"search.exclude": {
"**/.fvm": true
diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml
index c26e1c6649..c04e1dafdc 100644
--- a/mobile/analysis_options.yaml
+++ b/mobile/analysis_options.yaml
@@ -43,8 +43,9 @@ analyzer:
- lib/**/*.g.dart
- lib/**/*.drift.dart
- plugins:
- - custom_lint
+ # TODO: Re-enable after upgrading custom_lint
+ # plugins:
+ # - custom_lint
custom_lint:
debug: true
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/ImmichApp.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/ImmichApp.kt
index 4237643233..5a3b0e1f3d 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/ImmichApp.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/ImmichApp.kt
@@ -3,6 +3,7 @@ package app.alextran.immich
import android.app.Application
import androidx.work.Configuration
import androidx.work.WorkManager
+import app.alextran.immich.background.BackgroundWorkerApiImpl
class ImmichApp : Application() {
override fun onCreate() {
@@ -14,6 +15,8 @@ class ImmichApp : Application() {
// Thus, the BackupWorker is not started. If the system kills the process after each initialization
// (because of low memory etc.), the backup is never performed.
// As a workaround, we also run a backup check when initializing the application
+
ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0)
+ BackgroundWorkerApiImpl.enqueueBackgroundWorker(this)
}
}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt
index d395cc2243..4e811c8dfc 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt
@@ -3,6 +3,7 @@ package app.alextran.immich
import android.content.Context
import android.os.Build
import android.os.ext.SdkExtensions
+import app.alextran.immich.background.BackgroundEngineLock
import app.alextran.immich.background.BackgroundWorkerApiImpl
import app.alextran.immich.background.BackgroundWorkerFgHostApi
import app.alextran.immich.connectivity.ConnectivityApi
@@ -25,6 +26,7 @@ class MainActivity : FlutterFragmentActivity() {
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
flutterEngine.plugins.add(BackgroundServicePlugin())
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
+ flutterEngine.plugins.add(BackgroundEngineLock())
val messenger = flutterEngine.dartExecutor.binaryMessenger
val nativeSyncApiImpl =
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundEngineLock.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundEngineLock.kt
new file mode 100644
index 0000000000..6d6f45a708
--- /dev/null
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundEngineLock.kt
@@ -0,0 +1,33 @@
+package app.alextran.immich.background
+
+import android.util.Log
+import androidx.work.WorkManager
+import io.flutter.embedding.engine.FlutterEngineCache
+import io.flutter.embedding.engine.plugins.FlutterPlugin
+import java.util.concurrent.atomic.AtomicInteger
+
+private const val TAG = "BackgroundEngineLock"
+
+class BackgroundEngineLock : FlutterPlugin {
+ companion object {
+ const val ENGINE_CACHE_KEY = "immich::background_worker::engine"
+ var engineCount = AtomicInteger(0)
+ }
+
+ override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
+ // work manager task is running while the main app is opened, cancel the worker
+ if (engineCount.incrementAndGet() > 1 && FlutterEngineCache.getInstance()
+ .get(ENGINE_CACHE_KEY) != null
+ ) {
+ WorkManager.getInstance(binding.applicationContext)
+ .cancelUniqueWork(BackgroundWorkerApiImpl.BACKGROUND_WORKER_NAME)
+ FlutterEngineCache.getInstance().remove(ENGINE_CACHE_KEY)
+ }
+ Log.i(TAG, "Flutter engine attached. Attached Engines count: $engineCount")
+ }
+
+ override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
+ engineCount.decrementAndGet()
+ Log.i(TAG, "Flutter engine detached. Attached Engines count: $engineCount")
+ }
+}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt
index 4d9e2c0caf..052395c172 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt
@@ -37,6 +37,36 @@ private object BackgroundWorkerPigeonUtils {
)
}
}
+ fun deepEquals(a: Any?, b: Any?): Boolean {
+ 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) {
+ return a.contentEquals(b)
+ }
+ if (a is Array<*> && b is Array<*>) {
+ return a.size == b.size &&
+ a.indices.all{ deepEquals(a[it], b[it]) }
+ }
+ if (a is List<*> && b is List<*>) {
+ return a.size == b.size &&
+ a.indices.all{ deepEquals(a[it], b[it]) }
+ }
+ if (a is Map<*, *> && b is Map<*, *>) {
+ return a.size == b.size && a.all {
+ (b as Map).containsKey(it.key) &&
+ deepEquals(it.value, b[it.key])
+ }
+ }
+ return a == b
+ }
+
}
/**
@@ -50,18 +80,63 @@ class FlutterError (
override val message: String? = null,
val details: Any? = null
) : Throwable()
+
+/** Generated class from Pigeon that represents data sent in messages. */
+data class BackgroundWorkerSettings (
+ val requiresCharging: Boolean,
+ val minimumDelaySeconds: Long
+)
+ {
+ companion object {
+ fun fromList(pigeonVar_list: List): BackgroundWorkerSettings {
+ val requiresCharging = pigeonVar_list[0] as Boolean
+ val minimumDelaySeconds = pigeonVar_list[1] as Long
+ return BackgroundWorkerSettings(requiresCharging, minimumDelaySeconds)
+ }
+ }
+ fun toList(): List {
+ return listOf(
+ requiresCharging,
+ minimumDelaySeconds,
+ )
+ }
+ override fun equals(other: Any?): Boolean {
+ if (other !is BackgroundWorkerSettings) {
+ return false
+ }
+ if (this === other) {
+ return true
+ }
+ return BackgroundWorkerPigeonUtils.deepEquals(toList(), other.toList()) }
+
+ override fun hashCode(): Int = toList().hashCode()
+}
private open class BackgroundWorkerPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
- return super.readValueOfType(type, buffer)
+ return when (type) {
+ 129.toByte() -> {
+ return (readValue(buffer) as? List)?.let {
+ BackgroundWorkerSettings.fromList(it)
+ }
+ }
+ else -> super.readValueOfType(type, buffer)
+ }
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
- super.writeValue(stream, value)
+ when (value) {
+ is BackgroundWorkerSettings -> {
+ 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 BackgroundWorkerFgHostApi {
fun enable()
+ fun configure(settings: BackgroundWorkerSettings)
fun disable()
companion object {
@@ -89,6 +164,24 @@ interface BackgroundWorkerFgHostApi {
channel.setMessageHandler(null)
}
}
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.configure$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { message, reply ->
+ val args = message as List
+ val settingsArg = args[0] as BackgroundWorkerSettings
+ val wrapped: List = try {
+ api.configure(settingsArg)
+ listOf(null)
+ } catch (exception: Throwable) {
+ BackgroundWorkerPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
run {
val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$separatedMessageChannelSuffix", codec)
if (api != null) {
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt
index 33eb60dc82..7d30319af4 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt
@@ -19,6 +19,7 @@ import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.SettableFuture
import io.flutter.FlutterInjector
import io.flutter.embedding.engine.FlutterEngine
+import io.flutter.embedding.engine.FlutterEngineCache
import io.flutter.embedding.engine.dart.DartExecutor
import io.flutter.embedding.engine.loader.FlutterLoader
import java.util.concurrent.TimeUnit
@@ -75,6 +76,9 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
loader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
engine = FlutterEngine(ctx)
+ FlutterEngineCache.getInstance().remove(BackgroundEngineLock.ENGINE_CACHE_KEY);
+ FlutterEngineCache.getInstance()
+ .put(BackgroundEngineLock.ENGINE_CACHE_KEY, engine!!)
// Register custom plugins
MainActivity.registerPlugins(ctx, engine!!)
@@ -188,6 +192,7 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
isComplete = true
engine?.destroy()
engine = null
+ FlutterEngineCache.getInstance().remove(BackgroundEngineLock.ENGINE_CACHE_KEY);
flutterApi = null
notificationManager.cancel(NOTIFICATION_ID)
waitForForegroundPromotion()
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerApiImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerApiImpl.kt
index 4c2d98be71..259e3244bc 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerApiImpl.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerApiImpl.kt
@@ -1,6 +1,7 @@
package app.alextran.immich.background
import android.content.Context
+import android.content.SharedPreferences
import android.provider.MediaStore
import android.util.Log
import androidx.work.BackoffPolicy
@@ -10,7 +11,7 @@ import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import java.util.concurrent.TimeUnit
-private const val TAG = "BackgroundUploadImpl"
+private const val TAG = "BackgroundWorkerApiImpl"
class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
private val ctx: Context = context.applicationContext
@@ -19,25 +20,34 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
enqueueMediaObserver(ctx)
}
+ override fun configure(settings: BackgroundWorkerSettings) {
+ BackgroundWorkerPreferences(ctx).updateSettings(settings)
+ enqueueMediaObserver(ctx)
+ }
+
override fun disable() {
- WorkManager.getInstance(ctx).cancelUniqueWork(OBSERVER_WORKER_NAME)
- WorkManager.getInstance(ctx).cancelUniqueWork(BACKGROUND_WORKER_NAME)
+ WorkManager.getInstance(ctx).apply {
+ cancelUniqueWork(OBSERVER_WORKER_NAME)
+ cancelUniqueWork(BACKGROUND_WORKER_NAME)
+ }
Log.i(TAG, "Cancelled background upload tasks")
}
companion object {
- private const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1"
+ const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1"
private const val OBSERVER_WORKER_NAME = "immich/MediaObserverV1"
fun enqueueMediaObserver(ctx: Context) {
- val constraints = Constraints.Builder()
- .addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
- .addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
- .addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
- .addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
- .setTriggerContentUpdateDelay(30, TimeUnit.SECONDS)
- .setTriggerContentMaxDelay(3, TimeUnit.MINUTES)
- .build()
+ val settings = BackgroundWorkerPreferences(ctx).getSettings()
+ val constraints = Constraints.Builder().apply {
+ addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
+ addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
+ addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
+ addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
+ setTriggerContentUpdateDelay(settings.minimumDelaySeconds, TimeUnit.SECONDS)
+ setTriggerContentMaxDelay(settings.minimumDelaySeconds * 10, TimeUnit.MINUTES)
+ setRequiresCharging(settings.requiresCharging)
+ }.build()
val work = OneTimeWorkRequest.Builder(MediaObserver::class.java)
.setConstraints(constraints)
@@ -45,7 +55,10 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
WorkManager.getInstance(ctx)
.enqueueUniqueWork(OBSERVER_WORKER_NAME, ExistingWorkPolicy.REPLACE, work)
- Log.i(TAG, "Enqueued media observer worker with name: $OBSERVER_WORKER_NAME")
+ Log.i(
+ TAG,
+ "Enqueued media observer worker with name: $OBSERVER_WORKER_NAME and settings: $settings"
+ )
}
fun enqueueBackgroundWorker(ctx: Context) {
@@ -56,9 +69,39 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(ctx)
- .enqueueUniqueWork(BACKGROUND_WORKER_NAME, ExistingWorkPolicy.REPLACE, work)
+ .enqueueUniqueWork(BACKGROUND_WORKER_NAME, ExistingWorkPolicy.KEEP, work)
Log.i(TAG, "Enqueued background worker with name: $BACKGROUND_WORKER_NAME")
}
}
}
+
+private class BackgroundWorkerPreferences(private val ctx: Context) {
+ companion object {
+ private const val SHARED_PREF_NAME = "Immich::BackgroundWorker"
+ private const val SHARED_PREF_MIN_DELAY_KEY = "BackgroundWorker::minDelaySeconds"
+ private const val SHARED_PREF_REQUIRE_CHARGING_KEY = "BackgroundWorker::requireCharging"
+
+ private const val DEFAULT_MIN_DELAY_SECONDS = 30L
+ private const val DEFAULT_REQUIRE_CHARGING = false
+ }
+
+ private val sp: SharedPreferences by lazy {
+ ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
+ }
+
+ fun updateSettings(settings: BackgroundWorkerSettings) {
+ sp.edit().apply {
+ putLong(SHARED_PREF_MIN_DELAY_KEY, settings.minimumDelaySeconds)
+ putBoolean(SHARED_PREF_REQUIRE_CHARGING_KEY, settings.requiresCharging)
+ apply()
+ }
+ }
+
+ fun getSettings(): BackgroundWorkerSettings {
+ return BackgroundWorkerSettings(
+ minimumDelaySeconds = sp.getLong(SHARED_PREF_MIN_DELAY_KEY, DEFAULT_MIN_DELAY_SECONDS),
+ requiresCharging = sp.getBoolean(SHARED_PREF_REQUIRE_CHARGING_KEY, DEFAULT_REQUIRE_CHARGING),
+ )
+ }
+}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbnailsImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbnailsImpl.kt
index 1b1716f55c..1ccd742d67 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbnailsImpl.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbnailsImpl.kt
@@ -8,7 +8,6 @@ import android.net.Uri
import android.os.Build
import android.os.CancellationSignal
import android.os.OperationCanceledException
-import android.provider.MediaStore
import android.provider.MediaStore.Images
import android.provider.MediaStore.Video
import android.util.Size
@@ -19,7 +18,6 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.Priority
import com.bumptech.glide.load.DecodeFormat
import java.util.Base64
-import java.util.HashMap
import java.util.concurrent.CancellationException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Future
@@ -202,8 +200,10 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
val source = ImageDecoder.createSource(resolver, uri)
signal.throwIfCanceled()
ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
- val sampleSize = max(1, min(info.size.width / targetWidth, info.size.height / targetHeight))
- decoder.setTargetSampleSize(sampleSize)
+ if (targetWidth > 0 && targetHeight > 0) {
+ val sample = max(1, min(info.size.width / targetWidth, info.size.height / targetHeight))
+ decoder.setTargetSampleSize(sample)
+ }
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))
}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt
index 9c618d9ed0..28400c803f 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt
@@ -209,6 +209,40 @@ data class SyncDelta (
override fun hashCode(): Int = toList().hashCode()
}
+
+/** Generated class from Pigeon that represents data sent in messages. */
+data class HashResult (
+ val assetId: String,
+ val error: String? = null,
+ val hash: String? = null
+)
+ {
+ companion object {
+ fun fromList(pigeonVar_list: List): HashResult {
+ val assetId = pigeonVar_list[0] as String
+ val error = pigeonVar_list[1] as String?
+ val hash = pigeonVar_list[2] as String?
+ return HashResult(assetId, error, hash)
+ }
+ }
+ fun toList(): List {
+ return listOf(
+ assetId,
+ error,
+ hash,
+ )
+ }
+ override fun equals(other: Any?): Boolean {
+ if (other !is HashResult) {
+ return false
+ }
+ if (this === other) {
+ return true
+ }
+ return MessagesPigeonUtils.deepEquals(toList(), other.toList()) }
+
+ override fun hashCode(): Int = toList().hashCode()
+}
private open class MessagesPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
@@ -227,6 +261,11 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
SyncDelta.fromList(it)
}
}
+ 132.toByte() -> {
+ return (readValue(buffer) as? List)?.let {
+ HashResult.fromList(it)
+ }
+ }
else -> super.readValueOfType(type, buffer)
}
}
@@ -244,11 +283,16 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
stream.write(131)
writeValue(stream, value.toList())
}
+ is HashResult -> {
+ stream.write(132)
+ writeValue(stream, value.toList())
+ }
else -> super.writeValue(stream, value)
}
}
}
+
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface NativeSyncApi {
fun shouldFullSync(): Boolean
@@ -259,7 +303,8 @@ interface NativeSyncApi {
fun getAlbums(): List
fun getAssetsCountSince(albumId: String, timestamp: Long): Long
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List
- fun hashPaths(paths: List): List
+ fun hashAssets(assetIds: List, allowNetworkAccess: Boolean, callback: (Result>) -> Unit)
+ fun cancelHashing()
companion object {
/** The codec used by NativeSyncApi. */
@@ -402,13 +447,33 @@ interface NativeSyncApi {
}
}
run {
- val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths$separatedMessageChannelSuffix", codec, taskQueue)
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List
- val pathsArg = args[0] as List
+ val assetIdsArg = args[0] as List
+ val allowNetworkAccessArg = args[1] as Boolean
+ api.hashAssets(assetIdsArg, allowNetworkAccessArg) { result: Result> ->
+ val error = result.exceptionOrNull()
+ if (error != null) {
+ reply.reply(MessagesPigeonUtils.wrapError(error))
+ } else {
+ val data = result.getOrNull()
+ reply.reply(MessagesPigeonUtils.wrapResult(data))
+ }
+ }
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
val wrapped: List = try {
- listOf(api.hashPaths(pathsArg))
+ api.cancelHashing()
+ listOf(null)
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt
index b2ceb8a9f2..868f3c6cdd 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt
@@ -1,14 +1,25 @@
package app.alextran.immich.sync
import android.annotation.SuppressLint
+import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import android.provider.MediaStore
-import android.util.Log
+import android.util.Base64
import androidx.core.database.getStringOrNull
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Semaphore
+import kotlinx.coroutines.sync.withPermit
import java.io.File
-import java.io.FileInputStream
import java.security.MessageDigest
+import kotlin.coroutines.cancellation.CancellationException
+import kotlin.coroutines.coroutineContext
sealed class AssetResult {
data class ValidAsset(val asset: PlatformAsset, val albumId: String) : AssetResult()
@@ -19,8 +30,12 @@ sealed class AssetResult {
open class NativeSyncApiImplBase(context: Context) {
private val ctx: Context = context.applicationContext
+ private var hashTask: Job? = null
+
companion object {
- private const val TAG = "NativeSyncApiImplBase"
+ private const val MAX_CONCURRENT_HASH_OPERATIONS = 16
+ private val hashSemaphore = Semaphore(MAX_CONCURRENT_HASH_OPERATIONS)
+ private const val HASHING_CANCELLED_CODE = "HASH_CANCELLED"
const val MEDIA_SELECTION =
"(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)"
@@ -215,23 +230,74 @@ open class NativeSyncApiImplBase(context: Context) {
.toList()
}
- fun hashPaths(paths: List): List {
- val buffer = ByteArray(HASH_BUFFER_SIZE)
- val digest = MessageDigest.getInstance("SHA-1")
+ fun hashAssets(
+ assetIds: List,
+ // allowNetworkAccess is only used on the iOS implementation
+ @Suppress("UNUSED_PARAMETER") allowNetworkAccess: Boolean,
+ callback: (Result>) -> Unit
+ ) {
+ if (assetIds.isEmpty()) {
+ callback(Result.success(emptyList()))
+ return
+ }
- return paths.map { path ->
+ hashTask?.cancel()
+ hashTask = CoroutineScope(Dispatchers.IO).launch {
try {
- FileInputStream(path).use { file ->
- var bytesRead: Int
- while (file.read(buffer).also { bytesRead = it } > 0) {
- digest.update(buffer, 0, bytesRead)
+ val results = assetIds.map { assetId ->
+ async {
+ hashSemaphore.withPermit {
+ ensureActive()
+ hashAsset(assetId)
+ }
}
- }
- digest.digest()
+ }.awaitAll()
+
+ callback(Result.success(results))
+ } catch (e: CancellationException) {
+ callback(
+ Result.failure(
+ FlutterError(
+ HASHING_CANCELLED_CODE,
+ "Hashing operation was cancelled",
+ null
+ )
+ )
+ )
} catch (e: Exception) {
- Log.w(TAG, "Failed to hash file $path: $e")
- null
+ callback(Result.failure(e))
}
}
}
+
+ private suspend fun hashAsset(assetId: String): HashResult {
+ return try {
+ val assetUri = ContentUris.withAppendedId(
+ MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
+ assetId.toLong()
+ )
+
+ val digest = MessageDigest.getInstance("SHA-1")
+ ctx.contentResolver.openInputStream(assetUri)?.use { inputStream ->
+ var bytesRead: Int
+ val buffer = ByteArray(HASH_BUFFER_SIZE)
+ while (inputStream.read(buffer).also { bytesRead = it } > 0) {
+ coroutineContext.ensureActive()
+ digest.update(buffer, 0, bytesRead)
+ }
+ } ?: return HashResult(assetId, "Cannot open input stream for asset", null)
+
+ val hashString = Base64.encodeToString(digest.digest(), Base64.NO_WRAP)
+ HashResult(assetId, null, hashString)
+ } catch (e: SecurityException) {
+ HashResult(assetId, "Permission denied accessing asset: ${e.message}", null)
+ } catch (e: Exception) {
+ HashResult(assetId, "Failed to hash asset: ${e.message}", null)
+ }
+ }
+
+ fun cancelHashing() {
+ hashTask?.cancel()
+ hashTask = null
+ }
}
diff --git a/mobile/drift_schemas/main/drift_schema_v11.json b/mobile/drift_schemas/main/drift_schema_v11.json
new file mode 100644
index 0000000000..1c100ab37f
--- /dev/null
+++ b/mobile/drift_schemas/main/drift_schema_v11.json
@@ -0,0 +1 @@
+{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":true},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"has_profile_image","getter_name":"hasProfileImage","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"has_profile_image\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"has_profile_image\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"profile_changed_at","getter_name":"profileChangedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"avatar_color","getter_name":"avatarColor","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AvatarColor.values)","dart_type_name":"AvatarColor"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":1,"references":[0],"type":"table","data":{"name":"remote_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"local_date_time","getter_name":"localDateTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"thumb_hash","getter_name":"thumbHash","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"live_photo_video_id","getter_name":"livePhotoVideoId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"visibility","getter_name":"visibility","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetVisibility.values)","dart_type_name":"AssetVisibility"}},{"name":"stack_id","getter_name":"stackId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"library_id","getter_name":"libraryId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":2,"references":[0],"type":"table","data":{"name":"stack_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"primary_asset_id","getter_name":"primaryAssetId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":3,"references":[],"type":"table","data":{"name":"local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":4,"references":[0,1],"type":"table","data":{"name":"remote_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('\\'\\'')","default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"thumbnail_asset_id","getter_name":"thumbnailAssetId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"is_activity_enabled","getter_name":"isActivityEnabled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_activity_enabled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_activity_enabled\" IN (0, 1))"},"default_dart":"const CustomExpression('1')","default_client_dart":null,"dsl_features":[]},{"name":"order","getter_name":"order","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumAssetOrder.values)","dart_type_name":"AlbumAssetOrder"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":5,"references":[4],"type":"table","data":{"name":"local_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"backup_selection","getter_name":"backupSelection","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(BackupSelection.values)","dart_type_name":"BackupSelection"}},{"name":"is_ios_shared_album","getter_name":"isIosSharedAlbum","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_ios_shared_album\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_ios_shared_album\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"linked_remote_album_id","getter_name":"linkedRemoteAlbumId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":6,"references":[3,5],"type":"table","data":{"name":"local_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":7,"references":[3],"type":"index","data":{"on":3,"name":"idx_local_asset_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)","unique":false,"columns":[]}},{"id":8,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_owner_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)","unique":false,"columns":[]}},{"id":9,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_assets_owner_checksum","sql":"CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum\nON remote_asset_entity (owner_id, checksum)\nWHERE (library_id IS NULL);\n","unique":true,"columns":[]}},{"id":10,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_assets_owner_library_checksum","sql":"CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum\nON remote_asset_entity (owner_id, library_id, checksum)\nWHERE (library_id IS NOT NULL);\n","unique":true,"columns":[]}},{"id":11,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)","unique":false,"columns":[]}},{"id":12,"references":[],"type":"table","data":{"name":"auth_user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_admin","getter_name":"isAdmin","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_admin\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_admin\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"has_profile_image","getter_name":"hasProfileImage","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"has_profile_image\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"has_profile_image\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"profile_changed_at","getter_name":"profileChangedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"avatar_color","getter_name":"avatarColor","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AvatarColor.values)","dart_type_name":"AvatarColor"}},{"name":"quota_size_in_bytes","getter_name":"quotaSizeInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"quota_usage_in_bytes","getter_name":"quotaUsageInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"pin_code","getter_name":"pinCode","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":13,"references":[0],"type":"table","data":{"name":"user_metadata_entity","was_declared_in_moor":false,"columns":[{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"key","getter_name":"key","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(UserMetadataKey.values)","dart_type_name":"UserMetadataKey"}},{"name":"value","getter_name":"value","moor_type":"blob","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"userMetadataConverter","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["user_id","key"]}},{"id":14,"references":[0],"type":"table","data":{"name":"partner_entity","was_declared_in_moor":false,"columns":[{"name":"shared_by_id","getter_name":"sharedById","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"shared_with_id","getter_name":"sharedWithId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"in_timeline","getter_name":"inTimeline","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"in_timeline\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"in_timeline\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["shared_by_id","shared_with_id"]}},{"id":15,"references":[1],"type":"table","data":{"name":"remote_exif_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"city","getter_name":"city","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"state","getter_name":"state","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"country","getter_name":"country","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"date_time_original","getter_name":"dateTimeOriginal","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"exposure_time","getter_name":"exposureTime","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"f_number","getter_name":"fNumber","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"file_size","getter_name":"fileSize","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"focal_length","getter_name":"focalLength","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"iso","getter_name":"iso","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"make","getter_name":"make","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"model","getter_name":"model","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"lens","getter_name":"lens","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"time_zone","getter_name":"timeZone","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"rating","getter_name":"rating","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"projection_type","getter_name":"projectionType","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":16,"references":[1,4],"type":"table","data":{"name":"remote_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":17,"references":[4,0],"type":"table","data":{"name":"remote_album_user_entity","was_declared_in_moor":false,"columns":[{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"role","getter_name":"role","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumUserRole.values)","dart_type_name":"AlbumUserRole"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["album_id","user_id"]}},{"id":18,"references":[0],"type":"table","data":{"name":"memory_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(MemoryTypeEnum.values)","dart_type_name":"MemoryTypeEnum"}},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_saved","getter_name":"isSaved","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_saved\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_saved\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"memory_at","getter_name":"memoryAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"seen_at","getter_name":"seenAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"show_at","getter_name":"showAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"hide_at","getter_name":"hideAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":19,"references":[1,18],"type":"table","data":{"name":"memory_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"memory_id","getter_name":"memoryId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES memory_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES memory_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","memory_id"]}},{"id":20,"references":[0],"type":"table","data":{"name":"person_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"face_asset_id","getter_name":"faceAssetId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_hidden","getter_name":"isHidden","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_hidden\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_hidden\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"color","getter_name":"color","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"birth_date","getter_name":"birthDate","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":21,"references":[1,20],"type":"table","data":{"name":"asset_face_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"person_id","getter_name":"personId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES person_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES person_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"image_width","getter_name":"imageWidth","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"image_height","getter_name":"imageHeight","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_x1","getter_name":"boundingBoxX1","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_y1","getter_name":"boundingBoxY1","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_x2","getter_name":"boundingBoxX2","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_y2","getter_name":"boundingBoxY2","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"source_type","getter_name":"sourceType","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":22,"references":[],"type":"table","data":{"name":"store_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"string_value","getter_name":"stringValue","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"int_value","getter_name":"intValue","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":23,"references":[15],"type":"index","data":{"on":15,"name":"idx_lat_lng","sql":"CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)","unique":false,"columns":[]}}]}
\ No newline at end of file
diff --git a/mobile/ios/.gitignore b/mobile/ios/.gitignore
index f312f249a3..e32cadbf68 100644
--- a/mobile/ios/.gitignore
+++ b/mobile/ios/.gitignore
@@ -4,7 +4,6 @@
*.moved-aside
*.pbxuser
*.perspectivev3
-**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
diff --git a/mobile/ios/Flutter/AppFrameworkInfo.plist b/mobile/ios/Flutter/AppFrameworkInfo.plist
index 7c56964006..1dc6cf7652 100644
--- a/mobile/ios/Flutter/AppFrameworkInfo.plist
+++ b/mobile/ios/Flutter/AppFrameworkInfo.plist
@@ -21,6 +21,6 @@
CFBundleVersion
1.0
MinimumOSVersion
- 12.0
+ 13.0
diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock
index 09bd36022b..502fd9008f 100644
--- a/mobile/ios/Podfile.lock
+++ b/mobile/ios/Podfile.lock
@@ -253,7 +253,7 @@ SPEC CHECKSUMS:
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
- Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
+ Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
diff --git a/mobile/ios/Runner/Background/BackgroundWorker.g.swift b/mobile/ios/Runner/Background/BackgroundWorker.g.swift
index 45a6402fe8..ece5cd5f64 100644
--- a/mobile/ios/Runner/Background/BackgroundWorker.g.swift
+++ b/mobile/ios/Runner/Background/BackgroundWorker.g.swift
@@ -50,11 +50,119 @@ private func nilOrValue(_ value: Any?) -> T? {
return value as! T?
}
+func deepEqualsBackgroundWorker(_ lhs: Any?, _ rhs: Any?) -> Bool {
+ let cleanLhs = nilOrValue(lhs) as Any?
+ let cleanRhs = nilOrValue(rhs) as Any?
+ switch (cleanLhs, cleanRhs) {
+ case (nil, nil):
+ return true
+
+ case (nil, _), (_, nil):
+ return false
+
+ case is (Void, Void):
+ return true
+
+ case let (cleanLhsHashable, cleanRhsHashable) as (AnyHashable, AnyHashable):
+ return cleanLhsHashable == cleanRhsHashable
+
+ case let (cleanLhsArray, cleanRhsArray) as ([Any?], [Any?]):
+ guard cleanLhsArray.count == cleanRhsArray.count else { return false }
+ for (index, element) in cleanLhsArray.enumerated() {
+ if !deepEqualsBackgroundWorker(element, cleanRhsArray[index]) {
+ return false
+ }
+ }
+ return true
+
+ case let (cleanLhsDictionary, cleanRhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]):
+ guard cleanLhsDictionary.count == cleanRhsDictionary.count else { return false }
+ for (key, cleanLhsValue) in cleanLhsDictionary {
+ guard cleanRhsDictionary.index(forKey: key) != nil else { return false }
+ if !deepEqualsBackgroundWorker(cleanLhsValue, cleanRhsDictionary[key]!) {
+ return false
+ }
+ }
+ return true
+
+ default:
+ // Any other type shouldn't be able to be used with pigeon. File an issue if you find this to be untrue.
+ return false
+ }
+}
+
+func deepHashBackgroundWorker(value: Any?, hasher: inout Hasher) {
+ if let valueList = value as? [AnyHashable] {
+ for item in valueList { deepHashBackgroundWorker(value: item, hasher: &hasher) }
+ return
+ }
+
+ if let valueDict = value as? [AnyHashable: AnyHashable] {
+ for key in valueDict.keys {
+ hasher.combine(key)
+ deepHashBackgroundWorker(value: valueDict[key]!, hasher: &hasher)
+ }
+ return
+ }
+
+ if let hashableValue = value as? AnyHashable {
+ hasher.combine(hashableValue.hashValue)
+ }
+
+ return hasher.combine(String(describing: value))
+}
+
+
+
+/// Generated class from Pigeon that represents data sent in messages.
+struct BackgroundWorkerSettings: Hashable {
+ var requiresCharging: Bool
+ var minimumDelaySeconds: Int64
+
+
+ // swift-format-ignore: AlwaysUseLowerCamelCase
+ static func fromList(_ pigeonVar_list: [Any?]) -> BackgroundWorkerSettings? {
+ let requiresCharging = pigeonVar_list[0] as! Bool
+ let minimumDelaySeconds = pigeonVar_list[1] as! Int64
+
+ return BackgroundWorkerSettings(
+ requiresCharging: requiresCharging,
+ minimumDelaySeconds: minimumDelaySeconds
+ )
+ }
+ func toList() -> [Any?] {
+ return [
+ requiresCharging,
+ minimumDelaySeconds,
+ ]
+ }
+ static func == (lhs: BackgroundWorkerSettings, rhs: BackgroundWorkerSettings) -> Bool {
+ return deepEqualsBackgroundWorker(lhs.toList(), rhs.toList()) }
+ func hash(into hasher: inout Hasher) {
+ deepHashBackgroundWorker(value: toList(), hasher: &hasher)
+ }
+}
private class BackgroundWorkerPigeonCodecReader: FlutterStandardReader {
+ override func readValue(ofType type: UInt8) -> Any? {
+ switch type {
+ case 129:
+ return BackgroundWorkerSettings.fromList(self.readValue() as! [Any?])
+ default:
+ return super.readValue(ofType: type)
+ }
+ }
}
private class BackgroundWorkerPigeonCodecWriter: FlutterStandardWriter {
+ override func writeValue(_ value: Any) {
+ if let value = value as? BackgroundWorkerSettings {
+ super.writeByte(129)
+ super.writeValue(value.toList())
+ } else {
+ super.writeValue(value)
+ }
+ }
}
private class BackgroundWorkerPigeonCodecReaderWriter: FlutterStandardReaderWriter {
@@ -74,6 +182,7 @@ class BackgroundWorkerPigeonCodec: FlutterStandardMessageCodec, @unchecked Senda
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol BackgroundWorkerFgHostApi {
func enable() throws
+ func configure(settings: BackgroundWorkerSettings) throws
func disable() throws
}
@@ -96,6 +205,21 @@ class BackgroundWorkerFgHostApiSetup {
} else {
enableChannel.setMessageHandler(nil)
}
+ let configureChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.configure\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
+ if let api = api {
+ configureChannel.setMessageHandler { message, reply in
+ let args = message as! [Any?]
+ let settingsArg = args[0] as! BackgroundWorkerSettings
+ do {
+ try api.configure(settings: settingsArg)
+ reply(wrapResult(nil))
+ } catch {
+ reply(wrapError(error))
+ }
+ }
+ } else {
+ configureChannel.setMessageHandler(nil)
+ }
let disableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
disableChannel.setMessageHandler { _, reply in
diff --git a/mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift b/mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift
index 941e90cd44..f7f8f69989 100644
--- a/mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift
+++ b/mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift
@@ -5,17 +5,22 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
func enable() throws {
BackgroundWorkerApiImpl.scheduleRefreshWorker()
BackgroundWorkerApiImpl.scheduleProcessingWorker()
- print("BackgroundUploadImpl:enbale Background worker scheduled")
+ print("BackgroundWorkerApiImpl:enable Background worker scheduled")
+ }
+
+ func configure(settings: BackgroundWorkerSettings) throws {
+ // Android only
}
func disable() throws {
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.refreshTaskID);
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.processingTaskID);
- print("BackgroundUploadImpl:disableUploadWorker Disabled background workers")
+ print("BackgroundWorkerApiImpl:disableUploadWorker Disabled background workers")
}
private static let refreshTaskID = "app.alextran.immich.background.refreshUpload"
private static let processingTaskID = "app.alextran.immich.background.processingUpload"
+ private static let taskSemaphore = DispatchSemaphore(value: 1)
public static func registerBackgroundWorkers() {
BGTaskScheduler.shared.register(
@@ -59,12 +64,18 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
private static func handleBackgroundRefresh(task: BGAppRefreshTask) {
scheduleRefreshWorker()
- // Restrict the refresh task to run only for a maximum of (maxSeconds) seconds
- runBackgroundWorker(task: task, taskType: .refresh, maxSeconds: 20)
+ // If another task is running, cede the background time back to the OS
+ if taskSemaphore.wait(timeout: .now()) == .success {
+ // Restrict the refresh task to run only for a maximum of (maxSeconds) seconds
+ runBackgroundWorker(task: task, taskType: .refresh, maxSeconds: 20)
+ } else {
+ task.setTaskCompleted(success: false)
+ }
}
private static func handleBackgroundProcessing(task: BGProcessingTask) {
scheduleProcessingWorker()
+ taskSemaphore.wait()
// There are no restrictions for processing tasks. Although, the OS could signal expiration at any time
runBackgroundWorker(task: task, taskType: .processing, maxSeconds: nil)
}
@@ -80,6 +91,7 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
* - maxSeconds: Optional timeout for the operation in seconds
*/
private static func runBackgroundWorker(task: BGTask, taskType: BackgroundTaskType, maxSeconds: Int?) {
+ defer { taskSemaphore.signal() }
let semaphore = DispatchSemaphore(value: 0)
var isSuccess = true
diff --git a/mobile/ios/Runner/Images/ThumbnailsImpl.swift b/mobile/ios/Runner/Images/ThumbnailsImpl.swift
index d1ea2cc0e0..452ca62377 100644
--- a/mobile/ios/Runner/Images/ThumbnailsImpl.swift
+++ b/mobile/ios/Runner/Images/ThumbnailsImpl.swift
@@ -105,7 +105,7 @@ class ThumbnailApiImpl: ThumbnailApi {
var image: UIImage?
Self.imageManager.requestImage(
for: asset,
- targetSize: CGSize(width: Double(width), height: Double(height)),
+ targetSize: width > 0 && height > 0 ? CGSize(width: Double(width), height: Double(height)) : PHImageManagerMaximumSize,
contentMode: .aspectFill,
options: Self.requestOptions,
resultHandler: { (_image, info) -> Void in
diff --git a/mobile/ios/Runner/Sync/Messages.g.swift b/mobile/ios/Runner/Sync/Messages.g.swift
index 19f4384672..305aca5266 100644
--- a/mobile/ios/Runner/Sync/Messages.g.swift
+++ b/mobile/ios/Runner/Sync/Messages.g.swift
@@ -267,6 +267,39 @@ struct SyncDelta: Hashable {
}
}
+/// Generated class from Pigeon that represents data sent in messages.
+struct HashResult: Hashable {
+ var assetId: String
+ var error: String? = nil
+ var hash: String? = nil
+
+
+ // swift-format-ignore: AlwaysUseLowerCamelCase
+ static func fromList(_ pigeonVar_list: [Any?]) -> HashResult? {
+ let assetId = pigeonVar_list[0] as! String
+ let error: String? = nilOrValue(pigeonVar_list[1])
+ let hash: String? = nilOrValue(pigeonVar_list[2])
+
+ return HashResult(
+ assetId: assetId,
+ error: error,
+ hash: hash
+ )
+ }
+ func toList() -> [Any?] {
+ return [
+ assetId,
+ error,
+ hash,
+ ]
+ }
+ static func == (lhs: HashResult, rhs: HashResult) -> Bool {
+ return deepEqualsMessages(lhs.toList(), rhs.toList()) }
+ func hash(into hasher: inout Hasher) {
+ deepHashMessages(value: toList(), hasher: &hasher)
+ }
+}
+
private class MessagesPigeonCodecReader: FlutterStandardReader {
override func readValue(ofType type: UInt8) -> Any? {
switch type {
@@ -276,6 +309,8 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
return PlatformAlbum.fromList(self.readValue() as! [Any?])
case 131:
return SyncDelta.fromList(self.readValue() as! [Any?])
+ case 132:
+ return HashResult.fromList(self.readValue() as! [Any?])
default:
return super.readValue(ofType: type)
}
@@ -293,6 +328,9 @@ private class MessagesPigeonCodecWriter: FlutterStandardWriter {
} else if let value = value as? SyncDelta {
super.writeByte(131)
super.writeValue(value.toList())
+ } else if let value = value as? HashResult {
+ super.writeByte(132)
+ super.writeValue(value.toList())
} else {
super.writeValue(value)
}
@@ -313,6 +351,7 @@ class MessagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
static let shared = MessagesPigeonCodec(readerWriter: MessagesPigeonCodecReaderWriter())
}
+
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol NativeSyncApi {
func shouldFullSync() throws -> Bool
@@ -323,7 +362,8 @@ protocol NativeSyncApi {
func getAlbums() throws -> [PlatformAlbum]
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset]
- func hashPaths(paths: [String]) throws -> [FlutterStandardTypedData?]
+ func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
+ func cancelHashing() throws
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -459,22 +499,38 @@ class NativeSyncApiSetup {
} else {
getAssetsForAlbumChannel.setMessageHandler(nil)
}
- let hashPathsChannel = taskQueue == nil
- ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
- : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
+ let hashAssetsChannel = taskQueue == nil
+ ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
+ : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
- hashPathsChannel.setMessageHandler { message, reply in
+ hashAssetsChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
- let pathsArg = args[0] as! [String]
+ let assetIdsArg = args[0] as! [String]
+ let allowNetworkAccessArg = args[1] as! Bool
+ api.hashAssets(assetIds: assetIdsArg, allowNetworkAccess: allowNetworkAccessArg) { result in
+ switch result {
+ case .success(let res):
+ reply(wrapResult(res))
+ case .failure(let error):
+ reply(wrapError(error))
+ }
+ }
+ }
+ } else {
+ hashAssetsChannel.setMessageHandler(nil)
+ }
+ let cancelHashingChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
+ if let api = api {
+ cancelHashingChannel.setMessageHandler { _, reply in
do {
- let result = try api.hashPaths(paths: pathsArg)
- reply(wrapResult(result))
+ try api.cancelHashing()
+ reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
- hashPathsChannel.setMessageHandler(nil)
+ cancelHashingChannel.setMessageHandler(nil)
}
}
}
diff --git a/mobile/ios/Runner/Sync/MessagesImpl.swift b/mobile/ios/Runner/Sync/MessagesImpl.swift
index 2810dee7c1..bb23bae6b6 100644
--- a/mobile/ios/Runner/Sync/MessagesImpl.swift
+++ b/mobile/ios/Runner/Sync/MessagesImpl.swift
@@ -17,30 +17,16 @@ struct AssetWrapper: Hashable, Equatable {
}
}
-extension PHAsset {
- func toPlatformAsset() -> PlatformAsset {
- return PlatformAsset(
- id: localIdentifier,
- name: title(),
- type: Int64(mediaType.rawValue),
- createdAt: creationDate.map { Int64($0.timeIntervalSince1970) },
- updatedAt: modificationDate.map { Int64($0.timeIntervalSince1970) },
- width: Int64(pixelWidth),
- height: Int64(pixelHeight),
- durationInSeconds: Int64(duration),
- orientation: 0,
- isFavorite: isFavorite
- )
- }
-}
-
class NativeSyncApiImpl: NativeSyncApi {
private let defaults: UserDefaults
private let changeTokenKey = "immich:changeToken"
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
private let recoveredAlbumSubType = 1000000219
- private let hashBufferSize = 2 * 1024 * 1024
+ private var hashTask: Task?
+ private static let hashCancelledCode = "HASH_CANCELLED"
+ private static let hashCancelled = Result<[HashResult], Error>.failure(PigeonError(code: hashCancelledCode, message: "Hashing cancelled", details: nil))
+
init(with defaults: UserDefaults = .standard) {
self.defaults = defaults
@@ -96,7 +82,7 @@ class NativeSyncApiImpl: NativeSyncApi {
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
for i in 0.. %@ OR modificationDate > %@", date, date)
}
-
+
let result = PHAsset.fetchAssets(in: album, options: options)
if(result.count == 0) {
return []
@@ -267,23 +253,114 @@ class NativeSyncApiImpl: NativeSyncApi {
return assets
}
- func hashPaths(paths: [String]) throws -> [FlutterStandardTypedData?] {
- return paths.map { path in
- guard let file = FileHandle(forReadingAtPath: path) else {
- print("Cannot open file: \(path)")
- return nil
- }
-
- var hasher = Insecure.SHA1()
- while autoreleasepool(invoking: {
- let chunk = file.readData(ofLength: hashBufferSize)
- guard !chunk.isEmpty else { return false }
- hasher.update(data: chunk)
- return true
- }) { }
-
- let digest = hasher.finalize()
- return FlutterStandardTypedData(bytes: Data(digest))
+ func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) {
+ if let prevTask = hashTask {
+ prevTask.cancel()
+ hashTask = nil
+ }
+ hashTask = Task { [weak self] in
+ var missingAssetIds = Set(assetIds)
+ var assets = [PHAsset]()
+ assets.reserveCapacity(assetIds.count)
+ PHAsset.fetchAssets(withLocalIdentifiers: assetIds, options: nil).enumerateObjects { (asset, _, stop) in
+ if Task.isCancelled {
+ stop.pointee = true
+ return
+ }
+ missingAssetIds.remove(asset.localIdentifier)
+ assets.append(asset)
}
+
+ if Task.isCancelled {
+ return completion(Self.hashCancelled)
+ }
+
+ await withTaskGroup(of: HashResult?.self) { taskGroup in
+ var results = [HashResult]()
+ results.reserveCapacity(assets.count)
+ for asset in assets {
+ if Task.isCancelled {
+ return completion(Self.hashCancelled)
+ }
+ taskGroup.addTask {
+ guard let self = self else { return nil }
+ return await self.hashAsset(asset, allowNetworkAccess: allowNetworkAccess)
+ }
+ }
+
+ for await result in taskGroup {
+ guard let result = result else {
+ return completion(Self.hashCancelled)
+ }
+ results.append(result)
+ }
+
+ for missing in missingAssetIds {
+ results.append(HashResult(assetId: missing, error: "Asset not found in library", hash: nil))
+ }
+
+ completion(.success(results))
+ }
+ }
+ }
+
+ func cancelHashing() {
+ hashTask?.cancel()
+ hashTask = nil
+ }
+
+ private func hashAsset(_ asset: PHAsset, allowNetworkAccess: Bool) async -> HashResult? {
+ class RequestRef {
+ var id: PHAssetResourceDataRequestID?
+ }
+ let requestRef = RequestRef()
+ return await withTaskCancellationHandler(operation: {
+ if Task.isCancelled {
+ return nil
+ }
+
+ guard let resource = asset.getResource() else {
+ return HashResult(assetId: asset.localIdentifier, error: "Cannot get asset resource", hash: nil)
+ }
+
+ if Task.isCancelled {
+ return nil
+ }
+
+ let options = PHAssetResourceRequestOptions()
+ options.isNetworkAccessAllowed = allowNetworkAccess
+
+ return await withCheckedContinuation { continuation in
+ var hasher = Insecure.SHA1()
+
+ requestRef.id = PHAssetResourceManager.default().requestData(
+ for: resource,
+ options: options,
+ dataReceivedHandler: { data in
+ hasher.update(data: data)
+ },
+ completionHandler: { error in
+ let result: HashResult? = switch (error) {
+ case let e as PHPhotosError where e.code == .userCancelled: nil
+ case let .some(e): HashResult(
+ assetId: asset.localIdentifier,
+ error: "Failed to hash asset: \(e.localizedDescription)",
+ hash: nil
+ )
+ case .none:
+ HashResult(
+ assetId: asset.localIdentifier,
+ error: nil,
+ hash: Data(hasher.finalize()).base64EncodedString()
+ )
+ }
+ continuation.resume(returning: result)
+ }
+ )
+ }
+ }, onCancel: {
+ guard let requestId = requestRef.id else { return }
+ PHAssetResourceManager.default().cancelDataRequest(requestId)
+ })
}
}
diff --git a/mobile/ios/Runner/Sync/PHAssetExtensions.swift b/mobile/ios/Runner/Sync/PHAssetExtensions.swift
new file mode 100644
index 0000000000..2b1ef6ac88
--- /dev/null
+++ b/mobile/ios/Runner/Sync/PHAssetExtensions.swift
@@ -0,0 +1,77 @@
+import Photos
+
+extension PHAsset {
+ func toPlatformAsset() -> PlatformAsset {
+ return PlatformAsset(
+ id: localIdentifier,
+ name: title,
+ type: Int64(mediaType.rawValue),
+ createdAt: creationDate.map { Int64($0.timeIntervalSince1970) },
+ updatedAt: modificationDate.map { Int64($0.timeIntervalSince1970) },
+ width: Int64(pixelWidth),
+ height: Int64(pixelHeight),
+ durationInSeconds: Int64(duration),
+ orientation: 0,
+ isFavorite: isFavorite
+ )
+ }
+
+ var title: String {
+ return filename ?? originalFilename ?? ""
+ }
+
+ var filename: String? {
+ return value(forKey: "filename") as? String
+ }
+
+ // This method is expected to be slow as it goes through the asset resources to fetch the originalFilename
+ var originalFilename: String? {
+ return getResource()?.originalFilename
+ }
+
+ func getResource() -> PHAssetResource? {
+ let resources = PHAssetResource.assetResources(for: self)
+
+ let filteredResources = resources.filter { $0.isMediaResource && isValidResourceType($0.type) }
+
+ guard !filteredResources.isEmpty else {
+ return nil
+ }
+
+ if filteredResources.count == 1 {
+ return filteredResources.first
+ }
+
+ if let currentResource = filteredResources.first(where: { $0.isCurrent }) {
+ return currentResource
+ }
+
+ if let fullSizeResource = filteredResources.first(where: { isFullSizeResourceType($0.type) }) {
+ return fullSizeResource
+ }
+
+ return nil
+ }
+
+ private func isValidResourceType(_ type: PHAssetResourceType) -> Bool {
+ switch mediaType {
+ case .image:
+ return [.photo, .alternatePhoto, .fullSizePhoto].contains(type)
+ case .video:
+ return [.video, .fullSizeVideo, .fullSizePairedVideo].contains(type)
+ default:
+ return false
+ }
+ }
+
+ private func isFullSizeResourceType(_ type: PHAssetResourceType) -> Bool {
+ switch mediaType {
+ case .image:
+ return type == .fullSizePhoto
+ case .video:
+ return type == .fullSizeVideo
+ default:
+ return false
+ }
+ }
+}
diff --git a/mobile/ios/Runner/Sync/PHAssetResourceExtensions.swift b/mobile/ios/Runner/Sync/PHAssetResourceExtensions.swift
new file mode 100644
index 0000000000..699d55a98d
--- /dev/null
+++ b/mobile/ios/Runner/Sync/PHAssetResourceExtensions.swift
@@ -0,0 +1,16 @@
+
+import Photos
+
+extension PHAssetResource {
+ var isCurrent: Bool {
+ return value(forKey: "isCurrent") as? Bool ?? false
+ }
+
+ var isMediaResource: Bool {
+ var isMedia = type != .adjustmentData
+ if #available(iOS 17, *) {
+ isMedia = isMedia && type != .photoProxy
+ }
+ return isMedia
+ }
+}
diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart
index 60c9b92cf9..7429616f14 100644
--- a/mobile/lib/constants/constants.dart
+++ b/mobile/lib/constants/constants.dart
@@ -1,3 +1,5 @@
+import 'dart:io';
+
const int noDbId = -9223372036854775808; // from Isar
const double downloadCompleted = -1;
const double downloadFailed = -2;
@@ -10,7 +12,7 @@ const int kSyncEventBatchSize = 5000;
const int kFetchLocalAssetsBatchSize = 40000;
// Hash batch limits
-const int kBatchHashFileLimit = 256;
+final int kBatchHashFileLimit = Platform.isIOS ? 32 : 512;
const int kBatchHashSizeLimit = 1024 * 1024 * 1024; // 1GB
// Secure storage keys
diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart
index a5fb5dd70e..84f675f68b 100644
--- a/mobile/lib/domain/services/asset.service.dart
+++ b/mobile/lib/domain/services/asset.service.dart
@@ -40,13 +40,12 @@ class AssetService {
Future> getStack(RemoteAsset asset) async {
if (asset.stackId == null) {
- return [];
+ return const [];
}
- return _remoteAssetRepository.getStackChildren(asset).then((assets) {
- // Include the primary asset in the stack as the first item
- return [asset, ...assets];
- });
+ final stack = await _remoteAssetRepository.getStackChildren(asset);
+ // Include the primary asset in the stack as the first item
+ return [asset, ...stack];
}
Future getExif(BaseAsset asset) async {
diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart
index 90e2ddff4c..78dd1e980f 100644
--- a/mobile/lib/domain/services/background_worker.service.dart
+++ b/mobile/lib/domain/services/background_worker.service.dart
@@ -43,6 +43,17 @@ class BackgroundWorkerFgService {
// TODO: Move this call to native side once old timeline is removed
Future enable() => _foregroundHostApi.enable();
+ Future configure({int? minimumDelaySeconds, bool? requireCharging}) => _foregroundHostApi.configure(
+ BackgroundWorkerSettings(
+ minimumDelaySeconds:
+ minimumDelaySeconds ??
+ Store.get(AppSettingsEnum.backupTriggerDelay.storeKey, AppSettingsEnum.backupTriggerDelay.defaultValue),
+ requiresCharging:
+ requireCharging ??
+ Store.get(AppSettingsEnum.backupRequireCharging.storeKey, AppSettingsEnum.backupRequireCharging.defaultValue),
+ ),
+ );
+
Future disable() => _foregroundHostApi.disable();
}
@@ -173,6 +184,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
try {
final backgroundSyncManager = _ref.read(backgroundSyncProvider);
+ final nativeSyncApi = _ref.read(nativeSyncApiProvider);
_isCleanedUp = true;
_ref.dispose();
@@ -188,7 +200,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
_drift.close(),
_driftLogger.close(),
backgroundSyncManager.cancel(),
- backgroundSyncManager.cancelLocal(),
+ nativeSyncApi.cancelHashing(),
];
if (_isar.isOpen) {
diff --git a/mobile/lib/domain/services/hash.service.dart b/mobile/lib/domain/services/hash.service.dart
index 8044b298d3..90f29b8bc1 100644
--- a/mobile/lib/domain/services/hash.service.dart
+++ b/mobile/lib/domain/services/hash.service.dart
@@ -1,20 +1,18 @@
-import 'dart:convert';
-
+import 'package:flutter/services.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
-import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:logging/logging.dart';
+const String _kHashCancelledCode = "HASH_CANCELLED";
+
class HashService {
- final int batchSizeLimit;
- final int batchFileLimit;
+ final int _batchSize;
final DriftLocalAlbumRepository _localAlbumRepository;
final DriftLocalAssetRepository _localAssetRepository;
- final StorageRepository _storageRepository;
final NativeSyncApi _nativeSyncApi;
final bool Function()? _cancelChecker;
final _log = Logger('HashService');
@@ -22,37 +20,42 @@ class HashService {
HashService({
required DriftLocalAlbumRepository localAlbumRepository,
required DriftLocalAssetRepository localAssetRepository,
- required StorageRepository storageRepository,
required NativeSyncApi nativeSyncApi,
bool Function()? cancelChecker,
- this.batchSizeLimit = kBatchHashSizeLimit,
- this.batchFileLimit = kBatchHashFileLimit,
+ int? batchSize,
}) : _localAlbumRepository = localAlbumRepository,
_localAssetRepository = localAssetRepository,
- _storageRepository = storageRepository,
_cancelChecker = cancelChecker,
- _nativeSyncApi = nativeSyncApi;
+ _nativeSyncApi = nativeSyncApi,
+ _batchSize = batchSize ?? kBatchHashFileLimit;
bool get isCancelled => _cancelChecker?.call() ?? false;
Future hashAssets() async {
_log.info("Starting hashing of assets");
final Stopwatch stopwatch = Stopwatch()..start();
- // Sorted by backupSelection followed by isCloud
- final localAlbums = await _localAlbumRepository.getAll(
- sortBy: {SortLocalAlbumsBy.backupSelection, SortLocalAlbumsBy.isIosSharedAlbum},
- );
+ try {
+ // Sorted by backupSelection followed by isCloud
+ final localAlbums = await _localAlbumRepository.getBackupAlbums();
- for (final album in localAlbums) {
- if (isCancelled) {
- _log.warning("Hashing cancelled. Stopped processing albums.");
- break;
- }
+ for (final album in localAlbums) {
+ if (isCancelled) {
+ _log.warning("Hashing cancelled. Stopped processing albums.");
+ break;
+ }
- final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id);
- if (assetsToHash.isNotEmpty) {
- await _hashAssets(album, assetsToHash);
+ final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id);
+ if (assetsToHash.isNotEmpty) {
+ await _hashAssets(album, assetsToHash);
+ }
}
+ } on PlatformException catch (e) {
+ if (e.code == _kHashCancelledCode) {
+ _log.warning("Hashing cancelled by platform");
+ return;
+ }
+ } catch (e, s) {
+ _log.severe("Error during hashing", e, s);
}
stopwatch.stop();
@@ -63,8 +66,7 @@ class HashService {
/// with hash for those that were successfully hashed. Hashes are looked up in a table
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
Future _hashAssets(LocalAlbum album, List assetsToHash) async {
- int bytesProcessed = 0;
- final toHash = <_AssetToPath>[];
+ final toHash = {};
for (final asset in assetsToHash) {
if (isCancelled) {
@@ -72,21 +74,10 @@ class HashService {
return;
}
- final file = await _storageRepository.getFileForAsset(asset.id);
- if (file == null) {
- _log.warning(
- "Cannot get file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt} from album: ${album.name}",
- );
- continue;
- }
-
- bytesProcessed += await file.length();
- toHash.add(_AssetToPath(asset: asset, path: file.path));
-
- if (toHash.length >= batchFileLimit || bytesProcessed >= batchSizeLimit) {
+ toHash[asset.id] = asset;
+ if (toHash.length == _batchSize) {
await _processBatch(album, toHash);
toHash.clear();
- bytesProcessed = 0;
}
}
@@ -94,33 +85,36 @@ class HashService {
}
/// Processes a batch of assets.
- Future _processBatch(LocalAlbum album, List<_AssetToPath> toHash) async {
+ Future _processBatch(LocalAlbum album, Map toHash) async {
if (toHash.isEmpty) {
return;
}
_log.fine("Hashing ${toHash.length} files");
- final hashed = [];
- final hashes = await _nativeSyncApi.hashPaths(toHash.map((e) => e.path).toList());
+ final hashed = {};
+ final hashResults = await _nativeSyncApi.hashAssets(
+ toHash.keys.toList(),
+ allowNetworkAccess: album.backupSelection == BackupSelection.selected,
+ );
assert(
- hashes.length == toHash.length,
- "Hashes length does not match toHash length: ${hashes.length} != ${toHash.length}",
+ hashResults.length == toHash.length,
+ "Hashes length does not match toHash length: ${hashResults.length} != ${toHash.length}",
);
- for (int i = 0; i < hashes.length; i++) {
+ for (int i = 0; i < hashResults.length; i++) {
if (isCancelled) {
_log.warning("Hashing cancelled. Stopped processing batch.");
return;
}
- final hash = hashes[i];
- final asset = toHash[i].asset;
- if (hash?.length == 20) {
- hashed.add(asset.copyWith(checksum: base64.encode(hash!)));
+ final hashResult = hashResults[i];
+ if (hashResult.hash != null) {
+ hashed[hashResult.assetId] = hashResult.hash!;
} else {
+ final asset = toHash[hashResult.assetId];
_log.warning(
- "Failed to hash file for ${asset.id}: ${asset.name} created at ${asset.createdAt} from album: ${album.name}",
+ "Failed to hash asset with id: ${hashResult.assetId}, name: ${asset?.name}, createdAt: ${asset?.createdAt}, from album: ${album.name}. Error: ${hashResult.error ?? "unknown"}",
);
}
}
@@ -128,13 +122,5 @@ class HashService {
_log.fine("Hashed ${hashed.length}/${toHash.length} assets");
await _localAssetRepository.updateHashes(hashed);
- await _storageRepository.clearCache();
}
}
-
-class _AssetToPath {
- final LocalAsset asset;
- final String path;
-
- const _AssetToPath({required this.asset, required this.path});
-}
diff --git a/mobile/lib/infrastructure/entities/local_album_asset.entity.dart b/mobile/lib/infrastructure/entities/local_album_asset.entity.dart
index 8de879a09d..53f1a10662 100644
--- a/mobile/lib/infrastructure/entities/local_album_asset.entity.dart
+++ b/mobile/lib/infrastructure/entities/local_album_asset.entity.dart
@@ -10,6 +10,9 @@ class LocalAlbumAssetEntity extends Table with DriftDefaultsMixin {
TextColumn get albumId => text().references(LocalAlbumEntity, #id, onDelete: KeyAction.cascade)();
+ // Used for mark & sweep
+ BoolColumn get marker_ => boolean().nullable()();
+
@override
Set get primaryKey => {assetId, albumId};
}
diff --git a/mobile/lib/infrastructure/entities/local_album_asset.entity.drift.dart b/mobile/lib/infrastructure/entities/local_album_asset.entity.drift.dart
index 78da361f62..70c298332b 100644
--- a/mobile/lib/infrastructure/entities/local_album_asset.entity.drift.dart
+++ b/mobile/lib/infrastructure/entities/local_album_asset.entity.drift.dart
@@ -15,11 +15,13 @@ typedef $$LocalAlbumAssetEntityTableCreateCompanionBuilder =
i1.LocalAlbumAssetEntityCompanion Function({
required String assetId,
required String albumId,
+ i0.Value marker_,
});
typedef $$LocalAlbumAssetEntityTableUpdateCompanionBuilder =
i1.LocalAlbumAssetEntityCompanion Function({
i0.Value assetId,
i0.Value albumId,
+ i0.Value marker_,
});
final class $$LocalAlbumAssetEntityTableReferences
@@ -113,6 +115,11 @@ class $$LocalAlbumAssetEntityTableFilterComposer
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
+ i0.ColumnFilters get marker_ => $composableBuilder(
+ column: $table.marker_,
+ builder: (column) => i0.ColumnFilters(column),
+ );
+
i3.$$LocalAssetEntityTableFilterComposer get assetId {
final i3.$$LocalAssetEntityTableFilterComposer composer = $composerBuilder(
composer: this,
@@ -177,6 +184,11 @@ class $$LocalAlbumAssetEntityTableOrderingComposer
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
+ i0.ColumnOrderings get marker_ => $composableBuilder(
+ column: $table.marker_,
+ builder: (column) => i0.ColumnOrderings(column),
+ );
+
i3.$$LocalAssetEntityTableOrderingComposer get assetId {
final i3.$$LocalAssetEntityTableOrderingComposer composer =
$composerBuilder(
@@ -243,6 +255,9 @@ class $$LocalAlbumAssetEntityTableAnnotationComposer
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
+ i0.GeneratedColumn get marker_ =>
+ $composableBuilder(column: $table.marker_, builder: (column) => column);
+
i3.$$LocalAssetEntityTableAnnotationComposer get assetId {
final i3.$$LocalAssetEntityTableAnnotationComposer composer =
$composerBuilder(
@@ -344,16 +359,22 @@ class $$LocalAlbumAssetEntityTableTableManager
({
i0.Value assetId = const i0.Value.absent(),
i0.Value albumId = const i0.Value.absent(),
+ i0.Value marker_ = const i0.Value.absent(),
}) => i1.LocalAlbumAssetEntityCompanion(
assetId: assetId,
albumId: albumId,
+ marker_: marker_,
),
createCompanionCallback:
- ({required String assetId, required String albumId}) =>
- i1.LocalAlbumAssetEntityCompanion.insert(
- assetId: assetId,
- albumId: albumId,
- ),
+ ({
+ required String assetId,
+ required String albumId,
+ i0.Value marker_ = const i0.Value.absent(),
+ }) => i1.LocalAlbumAssetEntityCompanion.insert(
+ assetId: assetId,
+ albumId: albumId,
+ marker_: marker_,
+ ),
withReferenceMapper: (p0) => p0
.map(
(e) => (
@@ -477,8 +498,22 @@ class $LocalAlbumAssetEntityTable extends i2.LocalAlbumAssetEntity
'REFERENCES local_album_entity (id) ON DELETE CASCADE',
),
);
+ static const i0.VerificationMeta _marker_Meta = const i0.VerificationMeta(
+ 'marker_',
+ );
@override
- List get $columns => [assetId, albumId];
+ late final i0.GeneratedColumn marker_ = i0.GeneratedColumn(
+ 'marker',
+ aliasedName,
+ true,
+ type: i0.DriftSqlType.bool,
+ requiredDuringInsert: false,
+ defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
+ 'CHECK ("marker" IN (0, 1))',
+ ),
+ );
+ @override
+ List get $columns => [assetId, albumId, marker_];
@override
String get aliasedName => _alias ?? actualTableName;
@override
@@ -507,6 +542,12 @@ class $LocalAlbumAssetEntityTable extends i2.LocalAlbumAssetEntity
} else if (isInserting) {
context.missing(_albumIdMeta);
}
+ if (data.containsKey('marker')) {
+ context.handle(
+ _marker_Meta,
+ marker_.isAcceptableOrUnknown(data['marker']!, _marker_Meta),
+ );
+ }
return context;
}
@@ -527,6 +568,10 @@ class $LocalAlbumAssetEntityTable extends i2.LocalAlbumAssetEntity
i0.DriftSqlType.string,
data['${effectivePrefix}album_id'],
)!,
+ marker_: attachedDatabase.typeMapping.read(
+ i0.DriftSqlType.bool,
+ data['${effectivePrefix}marker'],
+ ),
);
}
@@ -545,15 +590,20 @@ class LocalAlbumAssetEntityData extends i0.DataClass
implements i0.Insertable {
final String assetId;
final String albumId;
+ final bool? marker_;
const LocalAlbumAssetEntityData({
required this.assetId,
required this.albumId,
+ this.marker_,
});
@override
Map toColumns(bool nullToAbsent) {
final map = {};
map['asset_id'] = i0.Variable(assetId);
map['album_id'] = i0.Variable(albumId);
+ if (!nullToAbsent || marker_ != null) {
+ map['marker'] = i0.Variable(marker_);
+ }
return map;
}
@@ -565,6 +615,7 @@ class LocalAlbumAssetEntityData extends i0.DataClass
return LocalAlbumAssetEntityData(
assetId: serializer.fromJson(json['assetId']),
albumId: serializer.fromJson(json['albumId']),
+ marker_: serializer.fromJson(json['marker_']),
);
}
@override
@@ -573,20 +624,26 @@ class LocalAlbumAssetEntityData extends i0.DataClass
return {
'assetId': serializer.toJson(assetId),
'albumId': serializer.toJson(albumId),
+ 'marker_': serializer.toJson(marker_),
};
}
- i1.LocalAlbumAssetEntityData copyWith({String? assetId, String? albumId}) =>
- i1.LocalAlbumAssetEntityData(
- assetId: assetId ?? this.assetId,
- albumId: albumId ?? this.albumId,
- );
+ i1.LocalAlbumAssetEntityData copyWith({
+ String? assetId,
+ String? albumId,
+ i0.Value marker_ = const i0.Value.absent(),
+ }) => i1.LocalAlbumAssetEntityData(
+ assetId: assetId ?? this.assetId,
+ albumId: albumId ?? this.albumId,
+ marker_: marker_.present ? marker_.value : this.marker_,
+ );
LocalAlbumAssetEntityData copyWithCompanion(
i1.LocalAlbumAssetEntityCompanion data,
) {
return LocalAlbumAssetEntityData(
assetId: data.assetId.present ? data.assetId.value : this.assetId,
albumId: data.albumId.present ? data.albumId.value : this.albumId,
+ marker_: data.marker_.present ? data.marker_.value : this.marker_,
);
}
@@ -594,51 +651,60 @@ class LocalAlbumAssetEntityData extends i0.DataClass
String toString() {
return (StringBuffer('LocalAlbumAssetEntityData(')
..write('assetId: $assetId, ')
- ..write('albumId: $albumId')
+ ..write('albumId: $albumId, ')
+ ..write('marker_: $marker_')
..write(')'))
.toString();
}
@override
- int get hashCode => Object.hash(assetId, albumId);
+ int get hashCode => Object.hash(assetId, albumId, marker_);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is i1.LocalAlbumAssetEntityData &&
other.assetId == this.assetId &&
- other.albumId == this.albumId);
+ other.albumId == this.albumId &&
+ other.marker_ == this.marker_);
}
class LocalAlbumAssetEntityCompanion
extends i0.UpdateCompanion {
final i0.Value assetId;
final i0.Value albumId;
+ final i0.Value marker_;
const LocalAlbumAssetEntityCompanion({
this.assetId = const i0.Value.absent(),
this.albumId = const i0.Value.absent(),
+ this.marker_ = const i0.Value.absent(),
});
LocalAlbumAssetEntityCompanion.insert({
required String assetId,
required String albumId,
+ this.marker_ = const i0.Value.absent(),
}) : assetId = i0.Value(assetId),
albumId = i0.Value(albumId);
static i0.Insertable custom({
i0.Expression? assetId,
i0.Expression? albumId,
+ i0.Expression? marker_,
}) {
return i0.RawValuesInsertable({
if (assetId != null) 'asset_id': assetId,
if (albumId != null) 'album_id': albumId,
+ if (marker_ != null) 'marker': marker_,
});
}
i1.LocalAlbumAssetEntityCompanion copyWith({
i0.Value? assetId,
i0.Value? albumId,
+ i0.Value? marker_,
}) {
return i1.LocalAlbumAssetEntityCompanion(
assetId: assetId ?? this.assetId,
albumId: albumId ?? this.albumId,
+ marker_: marker_ ?? this.marker_,
);
}
@@ -651,6 +717,9 @@ class LocalAlbumAssetEntityCompanion
if (albumId.present) {
map['album_id'] = i0.Variable(albumId.value);
}
+ if (marker_.present) {
+ map['marker'] = i0.Variable(marker_.value);
+ }
return map;
}
@@ -658,7 +727,8 @@ class LocalAlbumAssetEntityCompanion
String toString() {
return (StringBuffer('LocalAlbumAssetEntityCompanion(')
..write('assetId: $assetId, ')
- ..write('albumId: $albumId')
+ ..write('albumId: $albumId, ')
+ ..write('marker_: $marker_')
..write(')'))
.toString();
}
diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart
index f04ed27779..65d26d9747 100644
--- a/mobile/lib/infrastructure/repositories/db.repository.dart
+++ b/mobile/lib/infrastructure/repositories/db.repository.dart
@@ -93,7 +93,7 @@ class Drift extends $Drift implements IDatabaseRepository {
}
@override
- int get schemaVersion => 10;
+ int get schemaVersion => 11;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -156,6 +156,9 @@ class Drift extends $Drift implements IDatabaseRepository {
await m.addColumn(v10.userEntity, v10.userEntity.avatarColor);
await m.alterTable(TableMigration(v10.userEntity));
},
+ from10To11: (m, v11) async {
+ await m.addColumn(v11.localAlbumAssetEntity, v11.localAlbumAssetEntity.marker_);
+ },
),
);
diff --git a/mobile/lib/infrastructure/repositories/db.repository.steps.dart b/mobile/lib/infrastructure/repositories/db.repository.steps.dart
index be6d53d5a8..7910d9fcee 100644
--- a/mobile/lib/infrastructure/repositories/db.repository.steps.dart
+++ b/mobile/lib/infrastructure/repositories/db.repository.steps.dart
@@ -4270,6 +4270,395 @@ i1.GeneratedColumn _column_94(String aliasedName) =>
true,
type: i1.DriftSqlType.string,
);
+
+final class Schema11 extends i0.VersionedSchema {
+ Schema11({required super.database}) : super(version: 11);
+ @override
+ late final List entities = [
+ userEntity,
+ remoteAssetEntity,
+ stackEntity,
+ localAssetEntity,
+ remoteAlbumEntity,
+ localAlbumEntity,
+ localAlbumAssetEntity,
+ idxLocalAssetChecksum,
+ idxRemoteAssetOwnerChecksum,
+ uQRemoteAssetsOwnerChecksum,
+ uQRemoteAssetsOwnerLibraryChecksum,
+ idxRemoteAssetChecksum,
+ authUserEntity,
+ userMetadataEntity,
+ partnerEntity,
+ remoteExifEntity,
+ remoteAlbumAssetEntity,
+ remoteAlbumUserEntity,
+ memoryEntity,
+ memoryAssetEntity,
+ personEntity,
+ assetFaceEntity,
+ storeEntity,
+ idxLatLng,
+ ];
+ late final Shape20 userEntity = Shape20(
+ source: i0.VersionedTable(
+ entityName: 'user_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(id)'],
+ columns: [
+ _column_0,
+ _column_1,
+ _column_3,
+ _column_84,
+ _column_85,
+ _column_91,
+ ],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape17 remoteAssetEntity = Shape17(
+ source: i0.VersionedTable(
+ entityName: 'remote_asset_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(id)'],
+ columns: [
+ _column_1,
+ _column_8,
+ _column_9,
+ _column_5,
+ _column_10,
+ _column_11,
+ _column_12,
+ _column_0,
+ _column_13,
+ _column_14,
+ _column_15,
+ _column_16,
+ _column_17,
+ _column_18,
+ _column_19,
+ _column_20,
+ _column_21,
+ _column_86,
+ ],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape3 stackEntity = Shape3(
+ source: i0.VersionedTable(
+ entityName: 'stack_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(id)'],
+ columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape2 localAssetEntity = Shape2(
+ source: i0.VersionedTable(
+ entityName: 'local_asset_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(id)'],
+ columns: [
+ _column_1,
+ _column_8,
+ _column_9,
+ _column_5,
+ _column_10,
+ _column_11,
+ _column_12,
+ _column_0,
+ _column_22,
+ _column_14,
+ _column_23,
+ ],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape9 remoteAlbumEntity = Shape9(
+ source: i0.VersionedTable(
+ entityName: 'remote_album_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(id)'],
+ columns: [
+ _column_0,
+ _column_1,
+ _column_56,
+ _column_9,
+ _column_5,
+ _column_15,
+ _column_57,
+ _column_58,
+ _column_59,
+ ],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape19 localAlbumEntity = Shape19(
+ source: i0.VersionedTable(
+ entityName: 'local_album_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(id)'],
+ columns: [
+ _column_0,
+ _column_1,
+ _column_5,
+ _column_31,
+ _column_32,
+ _column_90,
+ _column_33,
+ ],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape22 localAlbumAssetEntity = Shape22(
+ source: i0.VersionedTable(
+ entityName: 'local_album_asset_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
+ columns: [_column_34, _column_35, _column_33],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ final i1.Index idxLocalAssetChecksum = i1.Index(
+ 'idx_local_asset_checksum',
+ 'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
+ );
+ final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
+ 'idx_remote_asset_owner_checksum',
+ 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
+ );
+ final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
+ 'UQ_remote_assets_owner_checksum',
+ 'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
+ );
+ final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
+ 'UQ_remote_assets_owner_library_checksum',
+ 'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
+ );
+ final i1.Index idxRemoteAssetChecksum = i1.Index(
+ 'idx_remote_asset_checksum',
+ 'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
+ );
+ late final Shape21 authUserEntity = Shape21(
+ source: i0.VersionedTable(
+ entityName: 'auth_user_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(id)'],
+ columns: [
+ _column_0,
+ _column_1,
+ _column_3,
+ _column_2,
+ _column_84,
+ _column_85,
+ _column_92,
+ _column_93,
+ _column_7,
+ _column_94,
+ ],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape4 userMetadataEntity = Shape4(
+ source: i0.VersionedTable(
+ entityName: 'user_metadata_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(user_id, "key")'],
+ columns: [_column_25, _column_26, _column_27],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape5 partnerEntity = Shape5(
+ source: i0.VersionedTable(
+ entityName: 'partner_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
+ columns: [_column_28, _column_29, _column_30],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape8 remoteExifEntity = Shape8(
+ source: i0.VersionedTable(
+ entityName: 'remote_exif_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(asset_id)'],
+ columns: [
+ _column_36,
+ _column_37,
+ _column_38,
+ _column_39,
+ _column_40,
+ _column_41,
+ _column_11,
+ _column_10,
+ _column_42,
+ _column_43,
+ _column_44,
+ _column_45,
+ _column_46,
+ _column_47,
+ _column_48,
+ _column_49,
+ _column_50,
+ _column_51,
+ _column_52,
+ _column_53,
+ _column_54,
+ _column_55,
+ ],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape7 remoteAlbumAssetEntity = Shape7(
+ source: i0.VersionedTable(
+ entityName: 'remote_album_asset_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
+ columns: [_column_36, _column_60],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape10 remoteAlbumUserEntity = Shape10(
+ source: i0.VersionedTable(
+ entityName: 'remote_album_user_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
+ columns: [_column_60, _column_25, _column_61],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape11 memoryEntity = Shape11(
+ source: i0.VersionedTable(
+ entityName: 'memory_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(id)'],
+ columns: [
+ _column_0,
+ _column_9,
+ _column_5,
+ _column_18,
+ _column_15,
+ _column_8,
+ _column_62,
+ _column_63,
+ _column_64,
+ _column_65,
+ _column_66,
+ _column_67,
+ ],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape12 memoryAssetEntity = Shape12(
+ source: i0.VersionedTable(
+ entityName: 'memory_asset_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
+ columns: [_column_36, _column_68],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape14 personEntity = Shape14(
+ source: i0.VersionedTable(
+ entityName: 'person_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(id)'],
+ columns: [
+ _column_0,
+ _column_9,
+ _column_5,
+ _column_15,
+ _column_1,
+ _column_69,
+ _column_71,
+ _column_72,
+ _column_73,
+ _column_74,
+ ],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape15 assetFaceEntity = Shape15(
+ source: i0.VersionedTable(
+ entityName: 'asset_face_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(id)'],
+ columns: [
+ _column_0,
+ _column_36,
+ _column_76,
+ _column_77,
+ _column_78,
+ _column_79,
+ _column_80,
+ _column_81,
+ _column_82,
+ _column_83,
+ ],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ late final Shape18 storeEntity = Shape18(
+ source: i0.VersionedTable(
+ entityName: 'store_entity',
+ withoutRowId: true,
+ isStrict: true,
+ tableConstraints: ['PRIMARY KEY(id)'],
+ columns: [_column_87, _column_88, _column_89],
+ attachedDatabase: database,
+ ),
+ alias: null,
+ );
+ final i1.Index idxLatLng = i1.Index(
+ 'idx_lat_lng',
+ 'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
+ );
+}
+
+class Shape22 extends i0.VersionedTable {
+ Shape22({required super.source, required super.alias}) : super.aliased();
+ i1.GeneratedColumn get assetId =>
+ columnsByName['asset_id']! as i1.GeneratedColumn;
+ i1.GeneratedColumn get albumId =>
+ columnsByName['album_id']! as i1.GeneratedColumn;
+ i1.GeneratedColumn get marker_ =>
+ columnsByName['marker']! as i1.GeneratedColumn;
+}
+
i0.MigrationStepWithVersion migrationSteps({
required Future Function(i1.Migrator m, Schema2 schema) from1To2,
required Future Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -4280,6 +4669,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future Function(i1.Migrator m, Schema8 schema) from7To8,
required Future Function(i1.Migrator m, Schema9 schema) from8To9,
required Future Function(i1.Migrator m, Schema10 schema) from9To10,
+ required Future Function(i1.Migrator m, Schema11 schema) from10To11,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@@ -4328,6 +4718,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from9To10(migrator, schema);
return 10;
+ case 10:
+ final schema = Schema11(database: database);
+ final migrator = i1.Migrator(database, schema);
+ await from10To11(migrator, schema);
+ return 11;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@@ -4344,6 +4739,7 @@ i1.OnUpgrade stepByStep({
required Future Function(i1.Migrator m, Schema8 schema) from7To8,
required Future Function(i1.Migrator m, Schema9 schema) from8To9,
required Future Function(i1.Migrator m, Schema10 schema) from9To10,
+ required Future Function(i1.Migrator m, Schema11 schema) from10To11,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@@ -4355,5 +4751,6 @@ i1.OnUpgrade stepByStep({
from7To8: from7To8,
from8To9: from8To9,
from9To10: from9To10,
+ from10To11: from10To11,
),
);
diff --git a/mobile/lib/infrastructure/repositories/local_album.repository.dart b/mobile/lib/infrastructure/repositories/local_album.repository.dart
index b97884c27f..e4bff24879 100644
--- a/mobile/lib/infrastructure/repositories/local_album.repository.dart
+++ b/mobile/lib/infrastructure/repositories/local_album.repository.dart
@@ -72,17 +72,33 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
return Future.value();
}
- final deleteSmt = _db.localAssetEntity.delete();
- deleteSmt.where((localAsset) {
- final subQuery = _db.localAlbumAssetEntity.selectOnly()
- ..addColumns([_db.localAlbumAssetEntity.assetId])
- ..join([innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id))]);
- subQuery.where(
- _db.localAlbumEntity.id.equals(albumId) & _db.localAlbumAssetEntity.assetId.isNotIn(assetIdsToKeep),
- );
- return localAsset.id.isInQuery(subQuery);
+ return _db.transaction(() async {
+ await _db.managers.localAlbumAssetEntity
+ .filter((row) => row.albumId.id.equals(albumId))
+ .update((album) => album(marker_: const Value(true)));
+
+ await _db.batch((batch) {
+ for (final assetId in assetIdsToKeep) {
+ batch.update(
+ _db.localAlbumAssetEntity,
+ const LocalAlbumAssetEntityCompanion(marker_: Value(null)),
+ where: (row) => row.assetId.equals(assetId) & row.albumId.equals(albumId),
+ );
+ }
+ });
+
+ final query = _db.localAssetEntity.delete()
+ ..where(
+ (row) => row.id.isInQuery(
+ _db.localAlbumAssetEntity.selectOnly()
+ ..addColumns([_db.localAlbumAssetEntity.assetId])
+ ..where(
+ _db.localAlbumAssetEntity.albumId.equals(albumId) & _db.localAlbumAssetEntity.marker_.isNotNull(),
+ ),
+ ),
+ );
+ await query.go();
});
- await deleteSmt.go();
}
Future upsert(
@@ -198,10 +214,9 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
// List
await _db.batch((batch) async {
assetAlbums.cast>().forEach((assetId, albumIds) {
- batch.deleteWhere(
- _db.localAlbumAssetEntity,
- (f) => f.albumId.isNotIn(albumIds.cast().nonNulls) & f.assetId.equals(assetId),
- );
+ for (final albumId in albumIds.cast().nonNulls) {
+ batch.deleteWhere(_db.localAlbumAssetEntity, (f) => f.albumId.equals(albumId) & f.assetId.equals(assetId));
+ }
});
});
await _db.batch((batch) async {
@@ -288,12 +303,14 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
return transaction(() async {
if (assetsToUnLink.isNotEmpty) {
- await _db.batch(
- (batch) => batch.deleteWhere(
- _db.localAlbumAssetEntity,
- (f) => f.assetId.isIn(assetsToUnLink) & f.albumId.equals(albumId),
- ),
- );
+ await _db.batch((batch) {
+ for (final assetId in assetsToUnLink) {
+ batch.deleteWhere(
+ _db.localAlbumAssetEntity,
+ (row) => row.assetId.equals(assetId) & row.albumId.equals(albumId),
+ );
+ }
+ });
}
await _deleteAssets(assetsToDelete);
@@ -320,7 +337,9 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
}
return _db.batch((batch) {
- batch.deleteWhere(_db.localAssetEntity, (f) => f.id.isIn(ids));
+ for (final id in ids) {
+ batch.deleteWhere(_db.localAssetEntity, (row) => row.id.equals(id));
+ }
});
}
diff --git a/mobile/lib/infrastructure/repositories/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart
index 05c8e06678..2b76472c9e 100644
--- a/mobile/lib/infrastructure/repositories/local_asset.repository.dart
+++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart
@@ -1,4 +1,3 @@
-import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -36,17 +35,17 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
Stream watch(String id) => _assetSelectable(id).watchSingleOrNull();
- Future updateHashes(Iterable hashes) {
+ Future updateHashes(Map hashes) {
if (hashes.isEmpty) {
return Future.value();
}
return _db.batch((batch) async {
- for (final asset in hashes) {
+ for (final entry in hashes.entries) {
batch.update(
_db.localAssetEntity,
- LocalAssetEntityCompanion(checksum: Value(asset.checksum)),
- where: (e) => e.id.equals(asset.id),
+ LocalAssetEntityCompanion(checksum: Value(entry.value)),
+ where: (e) => e.id.equals(entry.key),
);
}
});
@@ -58,8 +57,8 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
}
return _db.batch((batch) {
- for (final slice in ids.slices(32000)) {
- batch.deleteWhere(_db.localAssetEntity, (e) => e.id.isIn(slice));
+ for (final id in ids) {
+ batch.deleteWhere(_db.localAssetEntity, (e) => e.id.equals(id));
}
});
}
diff --git a/mobile/lib/infrastructure/repositories/remote_album.repository.dart b/mobile/lib/infrastructure/repositories/remote_album.repository.dart
index 78b56e7436..5dfe4ac9b3 100644
--- a/mobile/lib/infrastructure/repositories/remote_album.repository.dart
+++ b/mobile/lib/infrastructure/repositories/remote_album.repository.dart
@@ -166,8 +166,15 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
);
}
- Future removeAssets(String albumId, List assetIds) {
- return _db.remoteAlbumAssetEntity.deleteWhere((tbl) => tbl.albumId.equals(albumId) & tbl.assetId.isIn(assetIds));
+ Future removeAssets(String albumId, List assetIds) {
+ return _db.batch((batch) {
+ for (final assetId in assetIds) {
+ batch.deleteWhere(
+ _db.remoteAlbumAssetEntity,
+ (row) => row.albumId.equals(albumId) & row.assetId.equals(assetId),
+ );
+ }
+ });
}
FutureOr<(DateTime, DateTime)> getDateRange(String albumId) {
diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart
index 01aa10c7ad..092bb728d9 100644
--- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart
+++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart
@@ -62,12 +62,13 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
}
Future> getStackChildren(RemoteAsset asset) {
- if (asset.stackId == null) {
- return Future.value([]);
+ final stackId = asset.stackId;
+ if (stackId == null) {
+ return Future.value(const []);
}
final query = _db.remoteAssetEntity.select()
- ..where((row) => row.stackId.equals(asset.stackId!) & row.id.equals(asset.id).not())
+ ..where((row) => row.stackId.equals(stackId) & row.id.equals(asset.id).not())
..orderBy([(row) => OrderingTerm.desc(row.createdAt)]);
return query.map((row) => row.toDto()).get();
@@ -159,7 +160,11 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
}
Future delete(List ids) {
- return _db.remoteAssetEntity.deleteWhere((row) => row.id.isIn(ids));
+ return _db.batch((batch) {
+ for (final id in ids) {
+ batch.deleteWhere(_db.remoteAssetEntity, (row) => row.id.equals(id));
+ }
+ });
}
Future updateLocation(List ids, LatLng location) {
@@ -198,7 +203,11 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
.map((row) => row.id)
.get();
- await _db.stackEntity.deleteWhere((row) => row.id.isIn(stackIds));
+ await _db.batch((batch) {
+ for (final stackId in stackIds) {
+ batch.deleteWhere(_db.stackEntity, (row) => row.id.equals(stackId));
+ }
+ });
await _db.batch((batch) {
final companion = StackEntityCompanion(ownerId: Value(userId), primaryAssetId: Value(stack.primaryAssetId));
@@ -218,15 +227,21 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
Future unStack(List stackIds) {
return _db.transaction(() async {
- await _db.stackEntity.deleteWhere((row) => row.id.isIn(stackIds));
+ await _db.batch((batch) {
+ for (final stackId in stackIds) {
+ batch.deleteWhere(_db.stackEntity, (row) => row.id.equals(stackId));
+ }
+ });
// TODO: delete this after adding foreign key on stackId
await _db.batch((batch) {
- batch.update(
- _db.remoteAssetEntity,
- const RemoteAssetEntityCompanion(stackId: Value(null)),
- where: (e) => e.stackId.isIn(stackIds),
- );
+ for (final stackId in stackIds) {
+ batch.update(
+ _db.remoteAssetEntity,
+ const RemoteAssetEntityCompanion(stackId: Value(null)),
+ where: (e) => e.stackId.equals(stackId),
+ );
+ }
});
});
}
diff --git a/mobile/lib/infrastructure/repositories/search_api.repository.dart b/mobile/lib/infrastructure/repositories/search_api.repository.dart
index 129746120b..dd72333398 100644
--- a/mobile/lib/infrastructure/repositories/search_api.repository.dart
+++ b/mobile/lib/infrastructure/repositories/search_api.repository.dart
@@ -33,7 +33,7 @@ class SearchApiRepository extends ApiRepository {
personIds: filter.people.map((e) => e.id).toList(),
type: type,
page: page,
- size: 1000,
+ size: 100,
),
);
}
diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart
index 960a84435f..f4720fb110 100644
--- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart
+++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart
@@ -93,7 +93,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
Future deleteUsersV1(Iterable data) async {
try {
- await _db.userEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.userId)));
+ await _db.batch((batch) {
+ for (final user in data) {
+ batch.deleteWhere(_db.userEntity, (row) => row.id.equals(user.userId));
+ }
+ });
} catch (error, stack) {
_logger.severe('Error: SyncUserDeleteV1', error, stack);
rethrow;
@@ -158,7 +162,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
Future deleteAssetsV1(Iterable data, {String debugLabel = 'user'}) async {
try {
- await _db.remoteAssetEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.assetId)));
+ await _db.batch((batch) {
+ for (final asset in data) {
+ batch.deleteWhere(_db.remoteAssetEntity, (row) => row.id.equals(asset.assetId));
+ }
+ });
} catch (error, stack) {
_logger.severe('Error: deleteAssetsV1 - $debugLabel', error, stack);
rethrow;
@@ -243,7 +251,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
Future deleteAlbumsV1(Iterable data) async {
try {
- await _db.remoteAlbumEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.albumId)));
+ await _db.batch((batch) {
+ for (final album in data) {
+ batch.deleteWhere(_db.remoteAlbumEntity, (row) => row.id.equals(album.albumId));
+ }
+ });
} catch (error, stack) {
_logger.severe('Error: deleteAlbumsV1', error, stack);
rethrow;
@@ -379,7 +391,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
Future deleteMemoriesV1(Iterable data) async {
try {
- await _db.memoryEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.memoryId)));
+ await _db.batch((batch) {
+ for (final memory in data) {
+ batch.deleteWhere(_db.memoryEntity, (row) => row.id.equals(memory.memoryId));
+ }
+ });
} catch (error, stack) {
_logger.severe('Error: deleteMemoriesV1', error, stack);
rethrow;
@@ -443,7 +459,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
Future deleteStacksV1(Iterable data, {String debugLabel = 'user'}) async {
try {
- await _db.stackEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.stackId)));
+ await _db.batch((batch) {
+ for (final stack in data) {
+ batch.deleteWhere(_db.stackEntity, (row) => row.id.equals(stack.stackId));
+ }
+ });
} catch (error, stack) {
_logger.severe('Error: deleteStacksV1 - $debugLabel', error, stack);
rethrow;
diff --git a/mobile/lib/pages/album/album_options.page.dart b/mobile/lib/pages/album/album_options.page.dart
index e659340f26..20d4dbd325 100644
--- a/mobile/lib/pages/album/album_options.page.dart
+++ b/mobile/lib/pages/album/album_options.page.dart
@@ -169,7 +169,7 @@ class AlbumOptionsPage extends HookConsumerWidget {
album.activityEnabled = value;
}
},
- activeColor: activityEnabled.value ? context.primaryColor : context.themeData.disabledColor,
+ activeThumbColor: activityEnabled.value ? context.primaryColor : context.themeData.disabledColor,
dense: true,
title: Text(
"comments_and_likes",
diff --git a/mobile/lib/pages/backup/backup_controller.page.dart b/mobile/lib/pages/backup/backup_controller.page.dart
index 093ff952ae..4f55d00ea0 100644
--- a/mobile/lib/pages/backup/backup_controller.page.dart
+++ b/mobile/lib/pages/backup/backup_controller.page.dart
@@ -205,9 +205,9 @@ class BackupControllerPage extends HookConsumerWidget {
}
buildBackgroundBackupInfo() {
- return const ListTile(
- leading: Icon(Icons.info_outline_rounded),
- title: Text("Background backup is currently running, cannot start manual backup"),
+ return ListTile(
+ leading: const Icon(Icons.info_outline_rounded),
+ title: Text('background_backup_running_error'.tr()),
);
}
diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart
index bf9ad43f9c..8578506ced 100644
--- a/mobile/lib/pages/backup/drift_backup.page.dart
+++ b/mobile/lib/pages/backup/drift_backup.page.dart
@@ -12,6 +12,7 @@ import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.w
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
+import 'package:immich_mobile/providers/sync_status.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
@@ -33,7 +34,14 @@ class _DriftBackupPageState extends ConsumerState {
return;
}
- ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
+ WidgetsBinding.instance.addPostFrameCallback((_) async {
+ await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
+ await ref.read(backgroundSyncProvider).syncRemote();
+
+ if (mounted) {
+ await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
+ }
+ });
}
@override
@@ -44,7 +52,6 @@ class _DriftBackupPageState extends ConsumerState {
.toList();
final backupNotifier = ref.read(driftBackupProvider.notifier);
- final backgroundManager = ref.read(backgroundSyncProvider);
Future startBackup() async {
final currentUser = Store.tryGet(StoreKey.currentUser);
@@ -52,7 +59,6 @@ class _DriftBackupPageState extends ConsumerState {
return;
}
- await backgroundManager.syncRemote();
await backupNotifier.getBackupStatus(currentUser.id);
await backupNotifier.startBackup(currentUser.id);
}
@@ -235,11 +241,13 @@ class _BackupCard extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final backupCount = ref.watch(driftBackupProvider.select((p) => p.backupCount));
+ final syncStatus = ref.watch(syncStatusProvider);
return BackupInfoCard(
title: "backup_controller_page_backup".tr(),
subtitle: "backup_controller_page_backup_sub".tr(),
info: backupCount.toString(),
+ isLoading: syncStatus.isRemoteSyncing,
);
}
}
@@ -250,10 +258,13 @@ class _RemainderCard extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final remainderCount = ref.watch(driftBackupProvider.select((p) => p.remainderCount));
+ final syncStatus = ref.watch(syncStatusProvider);
+
return BackupInfoCard(
title: "backup_controller_page_remainder".tr(),
subtitle: "backup_controller_page_remainder_sub".tr(),
info: remainderCount.toString(),
+ isLoading: syncStatus.isRemoteSyncing,
onTap: () => context.pushRoute(const DriftBackupAssetDetailRoute()),
);
}
diff --git a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart
index e734dc300c..368341f24a 100644
--- a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart
+++ b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart
@@ -1,15 +1,19 @@
+import 'dart:async';
import 'dart:io';
import 'package:auto_route/auto_route.dart';
+import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
+import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
-import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
+import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
+import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart';
@@ -63,16 +67,6 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState p.totalCount));
- final totalChanged = currentTotalAssetCount != _initialTotalAssetCount;
- final isBackupEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
-
- if (totalChanged && isBackupEnabled) {
- await ref.read(driftBackupProvider.notifier).cancel();
- await ref.read(driftBackupProvider.notifier).startBackup(user.id);
- }
}
@override
@@ -101,6 +95,27 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState p.totalCount));
+ final totalChanged = currentTotalAssetCount != _initialTotalAssetCount;
+ final backupNotifier = ref.read(driftBackupProvider.notifier);
+ final backgroundSync = ref.read(backgroundSyncProvider);
+ final nativeSync = ref.read(nativeSyncApiProvider);
+ if (totalChanged) {
+ // Waits for hashing to be cancelled before starting a new one
+ unawaited(nativeSync.cancelHashing().whenComplete(() => backgroundSync.hashAssets()));
+ if (isBackupEnabled) {
+ unawaited(backupNotifier.cancel().whenComplete(() => backupNotifier.startBackup(user.id)));
+ }
+ }
+
Navigator.of(context).pop();
}
},
@@ -249,7 +264,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState
- Text('Error: $error', style: TextStyle(color: context.colorScheme.error)),
+ error: (error, stackTrace) => Text(
+ 'error_saving_image'.tr(args: [error.toString()]),
+ style: TextStyle(color: context.colorScheme.error),
+ ),
loading: () => const SizedBox(height: 16, width: 16, child: CircularProgressIndicator.adaptive()),
),
],
@@ -83,7 +86,7 @@ class DriftBackupAssetDetailPage extends ConsumerWidget {
);
},
error: (Object error, StackTrace stackTrace) {
- return Center(child: Text('Error: $error'));
+ return Center(child: Text('error_saving_image'.tr(args: [error.toString()])));
},
loading: () {
return const SizedBox(height: 48, width: 48, child: Center(child: CircularProgressIndicator.adaptive()));
diff --git a/mobile/lib/pages/common/app_log.page.dart b/mobile/lib/pages/common/app_log.page.dart
index fe0c0ea442..37aec2f13c 100644
--- a/mobile/lib/pages/common/app_log.page.dart
+++ b/mobile/lib/pages/common/app_log.page.dart
@@ -1,4 +1,5 @@
import 'package:auto_route/auto_route.dart';
+import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -8,7 +9,6 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/immich_logger.service.dart';
-import 'package:intl/intl.dart';
@RoutePage()
class AppLogPage extends HookConsumerWidget {
@@ -49,7 +49,7 @@ class AppLogPage extends HookConsumerWidget {
return Scaffold(
appBar: AppBar(
- title: const Text("Logs", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16.0)),
+ title: Text('logs'.tr(), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16.0)),
scrolledUnderElevation: 1,
elevation: 2,
actions: [
diff --git a/mobile/lib/pages/common/app_log_detail.page.dart b/mobile/lib/pages/common/app_log_detail.page.dart
index c9773f36e1..de9604b7ad 100644
--- a/mobile/lib/pages/common/app_log_detail.page.dart
+++ b/mobile/lib/pages/common/app_log_detail.page.dart
@@ -1,4 +1,5 @@
import 'package:auto_route/auto_route.dart';
+import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -36,7 +37,7 @@ class AppLogDetailPage extends HookConsumerWidget {
context.scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
- "Copied to clipboard",
+ "copied_to_clipboard".tr(),
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
),
),
@@ -97,7 +98,7 @@ class AppLogDetailPage extends HookConsumerWidget {
}
return Scaffold(
- appBar: AppBar(title: const Text("Log Detail")),
+ appBar: AppBar(title: Text("log_detail_title".tr())),
body: SafeArea(
child: ListView(
children: [
diff --git a/mobile/lib/pages/library/partner/drift_partner.page.dart b/mobile/lib/pages/library/partner/drift_partner.page.dart
index 834e55ffd4..d81cc44c76 100644
--- a/mobile/lib/pages/library/partner/drift_partner.page.dart
+++ b/mobile/lib/pages/library/partner/drift_partner.page.dart
@@ -134,7 +134,7 @@ class _SharedToPartnerList extends ConsumerWidget {
);
},
loading: () => const Center(child: CircularProgressIndicator()),
- error: (error, stack) => Center(child: Text("Error loading partners: $error")),
+ error: (error, stack) => Center(child: Text('error_loading_partners'.tr(args: [error.toString()]))),
);
}
}
diff --git a/mobile/lib/pages/library/places/places_collection.page.dart b/mobile/lib/pages/library/places/places_collection.page.dart
index 73c38a109c..f376709316 100644
--- a/mobile/lib/pages/library/places/places_collection.page.dart
+++ b/mobile/lib/pages/library/places/places_collection.page.dart
@@ -85,7 +85,7 @@ class PlacesCollectionPage extends HookConsumerWidget {
},
);
},
- error: (error, stask) => const Text('Error getting places'),
+ error: (error, stask) => Text('error_getting_places'.tr()),
loading: () => const Center(child: CircularProgressIndicator()),
),
],
diff --git a/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart b/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart
index c78a9d5138..8b66bb231f 100644
--- a/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart
+++ b/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart
@@ -112,7 +112,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
return SwitchListTile.adaptive(
value: showMetadata.value,
onChanged: newShareLink.value.isEmpty ? (value) => showMetadata.value = value : null,
- activeColor: colorScheme.primary,
+ activeThumbColor: colorScheme.primary,
dense: true,
title: Text("show_metadata", style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold)).tr(),
);
@@ -122,7 +122,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
return SwitchListTile.adaptive(
value: allowDownload.value,
onChanged: newShareLink.value.isEmpty ? (value) => allowDownload.value = value : null,
- activeColor: colorScheme.primary,
+ activeThumbColor: colorScheme.primary,
dense: true,
title: Text(
"allow_public_user_to_download",
@@ -135,7 +135,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
return SwitchListTile.adaptive(
value: allowUpload.value,
onChanged: newShareLink.value.isEmpty ? (value) => allowUpload.value = value : null,
- activeColor: colorScheme.primary,
+ activeThumbColor: colorScheme.primary,
dense: true,
title: Text(
"allow_public_user_to_upload",
@@ -148,7 +148,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
return SwitchListTile.adaptive(
value: editExpiry.value,
onChanged: newShareLink.value.isEmpty ? (value) => editExpiry.value = value : null,
- activeColor: colorScheme.primary,
+ activeThumbColor: colorScheme.primary,
dense: true,
title: Text(
"change_expiration_time",
diff --git a/mobile/lib/pages/search/search.page.dart b/mobile/lib/pages/search/search.page.dart
index 97205e000c..acfa2fd59f 100644
--- a/mobile/lib/pages/search/search.page.dart
+++ b/mobile/lib/pages/search/search.page.dart
@@ -435,7 +435,7 @@ class SearchPage extends HookConsumerWidget {
}
},
icon: const Icon(Icons.more_vert_rounded),
- tooltip: 'Show text search menu',
+ tooltip: 'show_text_search_menu'.tr(),
);
},
menuChildren: [
diff --git a/mobile/lib/platform/background_worker_api.g.dart b/mobile/lib/platform/background_worker_api.g.dart
index 9398b0a15b..af7c78fd4b 100644
--- a/mobile/lib/platform/background_worker_api.g.dart
+++ b/mobile/lib/platform/background_worker_api.g.dart
@@ -25,6 +25,57 @@ List